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
package/src/manager.ts
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import NativeNetworkQuality, {
|
|
4
|
+
type NativeMonitorOptions,
|
|
5
|
+
type NativeNetworkSnapshot,
|
|
6
|
+
type NativeProbeOptions,
|
|
7
|
+
type NativeProbeResult,
|
|
8
|
+
} from './NativeNetworkQuality';
|
|
9
|
+
import { classifyNetworkQuality } from './classify';
|
|
10
|
+
import { DEFAULT_CONFIG } from './constants';
|
|
11
|
+
import { NetworkQualityError } from './errors';
|
|
12
|
+
import { normalizeSnapshot } from './normalize';
|
|
13
|
+
import type {
|
|
14
|
+
DeepPartial,
|
|
15
|
+
NetworkQualityConfig,
|
|
16
|
+
NetworkQualityErrorCode,
|
|
17
|
+
NetworkQualityState,
|
|
18
|
+
NetworkSnapshot,
|
|
19
|
+
ProbeFailure,
|
|
20
|
+
ProbeConfig,
|
|
21
|
+
ProbeResult,
|
|
22
|
+
} from './types';
|
|
23
|
+
|
|
24
|
+
const MIN_AUTO_PROBE_INTERVAL_MS = 15_000;
|
|
25
|
+
const TRANSPORT_CHANGE_DEBOUNCE_MS = 2_000;
|
|
26
|
+
const MAX_LATENCY_SAMPLES = 100;
|
|
27
|
+
const MAX_NATIVE_TIMEOUT_MS = 2_147_483_647;
|
|
28
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
29
|
+
const SUPPORTED_ERROR_CODES = new Set<NetworkQualityErrorCode>([
|
|
30
|
+
'E_UNSUPPORTED',
|
|
31
|
+
'E_OFFLINE',
|
|
32
|
+
'E_INVALID_URL',
|
|
33
|
+
'E_PROBE_TIMEOUT',
|
|
34
|
+
'E_PROBE_FAILED',
|
|
35
|
+
'E_PROBE_SKIPPED',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
type RemovableSubscription = { remove(): void };
|
|
39
|
+
|
|
40
|
+
/** Minimal native surface consumed by the TypeScript manager. */
|
|
41
|
+
export interface NetworkQualityNativeModule {
|
|
42
|
+
getCurrentState(): Promise<NativeNetworkSnapshot>;
|
|
43
|
+
startMonitoring(options: NativeMonitorOptions): void;
|
|
44
|
+
stopMonitoring(): void;
|
|
45
|
+
probe(options: NativeProbeOptions): Promise<NativeProbeResult>;
|
|
46
|
+
onNetworkStateChange(
|
|
47
|
+
listener: (snapshot: NativeNetworkSnapshot) => void
|
|
48
|
+
): RemovableSubscription;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Minimal AppState surface consumed by the TypeScript manager. */
|
|
52
|
+
export interface NetworkQualityAppState {
|
|
53
|
+
readonly currentState: string | null | undefined;
|
|
54
|
+
addEventListener(
|
|
55
|
+
type: 'change',
|
|
56
|
+
listener: (state: AppStateStatus) => void
|
|
57
|
+
): RemovableSubscription;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ListenerRecord {
|
|
61
|
+
active: boolean;
|
|
62
|
+
listener: (state: NetworkQualityState) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ErrorLike {
|
|
66
|
+
code?: unknown;
|
|
67
|
+
message?: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cloneProbeConfig(config: ProbeConfig): ProbeConfig {
|
|
71
|
+
return { ...config };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cloneConfig(config: NetworkQualityConfig): NetworkQualityConfig {
|
|
75
|
+
return {
|
|
76
|
+
...config,
|
|
77
|
+
thresholds: {
|
|
78
|
+
excellent: { ...config.thresholds.excellent },
|
|
79
|
+
good: { ...config.thresholds.good },
|
|
80
|
+
moderate: { ...config.thresholds.moderate },
|
|
81
|
+
},
|
|
82
|
+
probe: cloneProbeConfig(config.probe),
|
|
83
|
+
autoProbe: { ...config.autoProbe },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mergeConfig(
|
|
88
|
+
current: NetworkQualityConfig,
|
|
89
|
+
patch: DeepPartial<NetworkQualityConfig>
|
|
90
|
+
): NetworkQualityConfig {
|
|
91
|
+
const valueOrCurrent = <T>(value: T | undefined, currentValue: T): T =>
|
|
92
|
+
value === undefined ? currentValue : value;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
throttleMs: valueOrCurrent(patch.throttleMs, current.throttleMs),
|
|
96
|
+
bandwidthChangeThresholdPct: valueOrCurrent(
|
|
97
|
+
patch.bandwidthChangeThresholdPct,
|
|
98
|
+
current.bandwidthChangeThresholdPct
|
|
99
|
+
),
|
|
100
|
+
validationGraceMs: valueOrCurrent(
|
|
101
|
+
patch.validationGraceMs,
|
|
102
|
+
current.validationGraceMs
|
|
103
|
+
),
|
|
104
|
+
thresholds: {
|
|
105
|
+
excellent: {
|
|
106
|
+
minDownlinkKbps: valueOrCurrent(
|
|
107
|
+
patch.thresholds?.excellent?.minDownlinkKbps,
|
|
108
|
+
current.thresholds.excellent.minDownlinkKbps
|
|
109
|
+
),
|
|
110
|
+
maxRttMs: valueOrCurrent(
|
|
111
|
+
patch.thresholds?.excellent?.maxRttMs,
|
|
112
|
+
current.thresholds.excellent.maxRttMs
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
good: {
|
|
116
|
+
minDownlinkKbps: valueOrCurrent(
|
|
117
|
+
patch.thresholds?.good?.minDownlinkKbps,
|
|
118
|
+
current.thresholds.good.minDownlinkKbps
|
|
119
|
+
),
|
|
120
|
+
maxRttMs: valueOrCurrent(
|
|
121
|
+
patch.thresholds?.good?.maxRttMs,
|
|
122
|
+
current.thresholds.good.maxRttMs
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
moderate: {
|
|
126
|
+
minDownlinkKbps: valueOrCurrent(
|
|
127
|
+
patch.thresholds?.moderate?.minDownlinkKbps,
|
|
128
|
+
current.thresholds.moderate.minDownlinkKbps
|
|
129
|
+
),
|
|
130
|
+
maxRttMs: valueOrCurrent(
|
|
131
|
+
patch.thresholds?.moderate?.maxRttMs,
|
|
132
|
+
current.thresholds.moderate.maxRttMs
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
probe: {
|
|
137
|
+
latencyUrl: valueOrCurrent(
|
|
138
|
+
patch.probe?.latencyUrl,
|
|
139
|
+
current.probe.latencyUrl
|
|
140
|
+
),
|
|
141
|
+
downloadUrl: valueOrCurrent(
|
|
142
|
+
patch.probe?.downloadUrl,
|
|
143
|
+
current.probe.downloadUrl
|
|
144
|
+
),
|
|
145
|
+
latencySamples: valueOrCurrent(
|
|
146
|
+
patch.probe?.latencySamples,
|
|
147
|
+
current.probe.latencySamples
|
|
148
|
+
),
|
|
149
|
+
timeoutMs: valueOrCurrent(
|
|
150
|
+
patch.probe?.timeoutMs,
|
|
151
|
+
current.probe.timeoutMs
|
|
152
|
+
),
|
|
153
|
+
downloadMaxDurationMs: valueOrCurrent(
|
|
154
|
+
patch.probe?.downloadMaxDurationMs,
|
|
155
|
+
current.probe.downloadMaxDurationMs
|
|
156
|
+
),
|
|
157
|
+
downloadMaxBytes: valueOrCurrent(
|
|
158
|
+
patch.probe?.downloadMaxBytes,
|
|
159
|
+
current.probe.downloadMaxBytes
|
|
160
|
+
),
|
|
161
|
+
resultTtlMs: valueOrCurrent(
|
|
162
|
+
patch.probe?.resultTtlMs,
|
|
163
|
+
current.probe.resultTtlMs
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
autoProbe: {
|
|
167
|
+
enabled: valueOrCurrent(
|
|
168
|
+
patch.autoProbe?.enabled,
|
|
169
|
+
current.autoProbe.enabled
|
|
170
|
+
),
|
|
171
|
+
intervalMs: valueOrCurrent(
|
|
172
|
+
patch.autoProbe?.intervalMs,
|
|
173
|
+
current.autoProbe.intervalMs
|
|
174
|
+
),
|
|
175
|
+
onTransportChange: valueOrCurrent(
|
|
176
|
+
patch.autoProbe?.onTransportChange,
|
|
177
|
+
current.autoProbe.onTransportChange
|
|
178
|
+
),
|
|
179
|
+
allowOnExpensive: valueOrCurrent(
|
|
180
|
+
patch.autoProbe?.allowOnExpensive,
|
|
181
|
+
current.autoProbe.allowOnExpensive
|
|
182
|
+
),
|
|
183
|
+
allowOnConstrained: valueOrCurrent(
|
|
184
|
+
patch.autoProbe?.allowOnConstrained,
|
|
185
|
+
current.autoProbe.allowOnConstrained
|
|
186
|
+
),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function assertFiniteNumber(
|
|
192
|
+
value: number,
|
|
193
|
+
path: string,
|
|
194
|
+
minimum: number,
|
|
195
|
+
exclusiveMinimum = false
|
|
196
|
+
): void {
|
|
197
|
+
const belowMinimum = exclusiveMinimum ? value <= minimum : value < minimum;
|
|
198
|
+
if (!Number.isFinite(value) || belowMinimum) {
|
|
199
|
+
const operator = exclusiveMinimum ? 'greater than' : 'at least';
|
|
200
|
+
throw new TypeError(
|
|
201
|
+
`${path} must be a finite number ${operator} ${minimum}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateProbeConfig(config: ProbeConfig): void {
|
|
207
|
+
if (typeof config.latencyUrl !== 'string' || config.latencyUrl.length === 0) {
|
|
208
|
+
throw new TypeError('probe.latencyUrl must be a non-empty string');
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
config.downloadUrl !== null &&
|
|
212
|
+
(typeof config.downloadUrl !== 'string' || config.downloadUrl.length === 0)
|
|
213
|
+
) {
|
|
214
|
+
throw new TypeError('probe.downloadUrl must be null or a non-empty string');
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
!Number.isInteger(config.latencySamples) ||
|
|
218
|
+
config.latencySamples < 1 ||
|
|
219
|
+
config.latencySamples > MAX_LATENCY_SAMPLES
|
|
220
|
+
) {
|
|
221
|
+
throw new TypeError(
|
|
222
|
+
`probe.latencySamples must be an integer between 1 and ${MAX_LATENCY_SAMPLES}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
assertFiniteNumber(config.timeoutMs, 'probe.timeoutMs', 0, true);
|
|
226
|
+
if (config.timeoutMs > MAX_NATIVE_TIMEOUT_MS) {
|
|
227
|
+
throw new TypeError(
|
|
228
|
+
`probe.timeoutMs must be at most ${MAX_NATIVE_TIMEOUT_MS}`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
assertFiniteNumber(
|
|
232
|
+
config.downloadMaxDurationMs,
|
|
233
|
+
'probe.downloadMaxDurationMs',
|
|
234
|
+
0,
|
|
235
|
+
true
|
|
236
|
+
);
|
|
237
|
+
if (config.downloadMaxDurationMs > MAX_NATIVE_TIMEOUT_MS) {
|
|
238
|
+
throw new TypeError(
|
|
239
|
+
`probe.downloadMaxDurationMs must be at most ${MAX_NATIVE_TIMEOUT_MS}`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
!Number.isInteger(config.downloadMaxBytes) ||
|
|
244
|
+
config.downloadMaxBytes <= 0 ||
|
|
245
|
+
config.downloadMaxBytes > MAX_NATIVE_TIMEOUT_MS
|
|
246
|
+
) {
|
|
247
|
+
throw new TypeError(
|
|
248
|
+
`probe.downloadMaxBytes must be an integer between 1 and ${MAX_NATIVE_TIMEOUT_MS}`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
assertFiniteNumber(config.resultTtlMs, 'probe.resultTtlMs', 0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function validateConfig(config: NetworkQualityConfig): NetworkQualityConfig {
|
|
255
|
+
assertFiniteNumber(config.throttleMs, 'throttleMs', 0);
|
|
256
|
+
assertFiniteNumber(
|
|
257
|
+
config.bandwidthChangeThresholdPct,
|
|
258
|
+
'bandwidthChangeThresholdPct',
|
|
259
|
+
0
|
|
260
|
+
);
|
|
261
|
+
assertFiniteNumber(config.validationGraceMs, 'validationGraceMs', 0);
|
|
262
|
+
if (config.validationGraceMs > MAX_TIMER_DELAY_MS) {
|
|
263
|
+
throw new TypeError(
|
|
264
|
+
`validationGraceMs must be at most ${MAX_TIMER_DELAY_MS}`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { excellent, good, moderate } = config.thresholds;
|
|
269
|
+
for (const [name, threshold] of Object.entries(config.thresholds)) {
|
|
270
|
+
assertFiniteNumber(
|
|
271
|
+
threshold.minDownlinkKbps,
|
|
272
|
+
`thresholds.${name}.minDownlinkKbps`,
|
|
273
|
+
0
|
|
274
|
+
);
|
|
275
|
+
assertFiniteNumber(threshold.maxRttMs, `thresholds.${name}.maxRttMs`, 0);
|
|
276
|
+
}
|
|
277
|
+
if (
|
|
278
|
+
excellent.minDownlinkKbps < good.minDownlinkKbps ||
|
|
279
|
+
good.minDownlinkKbps < moderate.minDownlinkKbps ||
|
|
280
|
+
excellent.maxRttMs > good.maxRttMs ||
|
|
281
|
+
good.maxRttMs > moderate.maxRttMs
|
|
282
|
+
) {
|
|
283
|
+
throw new TypeError(
|
|
284
|
+
'thresholds must be monotonic from excellent to good to moderate'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
validateProbeConfig(config.probe);
|
|
289
|
+
assertFiniteNumber(config.autoProbe.intervalMs, 'autoProbe.intervalMs', 0);
|
|
290
|
+
for (const [name, value] of Object.entries(config.autoProbe)) {
|
|
291
|
+
if (name !== 'intervalMs' && typeof value !== 'boolean') {
|
|
292
|
+
throw new TypeError(`autoProbe.${name} must be a boolean`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
...config,
|
|
298
|
+
autoProbe: {
|
|
299
|
+
...config.autoProbe,
|
|
300
|
+
intervalMs: Math.max(
|
|
301
|
+
MIN_AUTO_PROBE_INTERVAL_MS,
|
|
302
|
+
config.autoProbe.intervalMs
|
|
303
|
+
),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function stateEqualsIgnoringSnapshotTimestamp(
|
|
309
|
+
first: NetworkQualityState,
|
|
310
|
+
second: NetworkQualityState
|
|
311
|
+
): boolean {
|
|
312
|
+
return (
|
|
313
|
+
JSON.stringify({ ...first, timestamp: 0 }) ===
|
|
314
|
+
JSON.stringify({ ...second, timestamp: 0 })
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function errorMessage(error: unknown): string {
|
|
319
|
+
if (
|
|
320
|
+
typeof error === 'object' &&
|
|
321
|
+
error !== null &&
|
|
322
|
+
typeof (error as ErrorLike).message === 'string'
|
|
323
|
+
) {
|
|
324
|
+
return (error as ErrorLike).message as string;
|
|
325
|
+
}
|
|
326
|
+
return typeof error === 'string' ? error : 'The network probe failed';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function wrapProbeError(error: unknown): NetworkQualityError {
|
|
330
|
+
if (error instanceof NetworkQualityError) return error;
|
|
331
|
+
|
|
332
|
+
const candidate =
|
|
333
|
+
typeof error === 'object' && error !== null
|
|
334
|
+
? (error as ErrorLike).code
|
|
335
|
+
: undefined;
|
|
336
|
+
const code =
|
|
337
|
+
typeof candidate === 'string' &&
|
|
338
|
+
SUPPORTED_ERROR_CODES.has(candidate as NetworkQualityErrorCode)
|
|
339
|
+
? (candidate as NetworkQualityErrorCode)
|
|
340
|
+
: 'E_PROBE_FAILED';
|
|
341
|
+
return new NetworkQualityError(code, errorMessage(error), { cause: error });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function unsupportedError(): NetworkQualityError {
|
|
345
|
+
return new NetworkQualityError(
|
|
346
|
+
'E_UNSUPPORTED',
|
|
347
|
+
'rn-network-quality is unavailable. Run pod install on iOS, enable the React Native New Architecture, and rebuild the app. Expo Go is not supported; use an Expo development build.'
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Coordinates the native module, cached state, listeners, and auto-probes. */
|
|
352
|
+
export class NetworkQualityManager {
|
|
353
|
+
private readonly nativeModule: NetworkQualityNativeModule | null;
|
|
354
|
+
private readonly appStateApi: NetworkQualityAppState;
|
|
355
|
+
private config = cloneConfig(DEFAULT_CONFIG);
|
|
356
|
+
private readonly listeners = new Set<ListenerRecord>();
|
|
357
|
+
private nativeSubscription: RemovableSubscription | null = null;
|
|
358
|
+
private appStateSubscription: RemovableSubscription | null = null;
|
|
359
|
+
private latestSnapshot: NetworkSnapshot | null = null;
|
|
360
|
+
private cachedState: NetworkQualityState | null = null;
|
|
361
|
+
private lastProbe: ProbeResult | null = null;
|
|
362
|
+
private lastProbeFailure: ProbeFailure | null = null;
|
|
363
|
+
private lastProbeResultTtlMs: number | null = null;
|
|
364
|
+
private lastProbeFailureTtlMs: number | null = null;
|
|
365
|
+
private networkChangedAt: number | null = null;
|
|
366
|
+
private inFlightProbe: Promise<ProbeResult> | null = null;
|
|
367
|
+
private probeExpiry: ReturnType<typeof setTimeout> | null = null;
|
|
368
|
+
private validationGraceExpiry: ReturnType<typeof setTimeout> | null = null;
|
|
369
|
+
private autoProbeInterval: ReturnType<typeof setInterval> | null = null;
|
|
370
|
+
private transportDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
371
|
+
private appStateStatus: string | null | undefined;
|
|
372
|
+
private monitoringStopVersion = 0;
|
|
373
|
+
private stateVersion = 0;
|
|
374
|
+
|
|
375
|
+
public constructor(
|
|
376
|
+
nativeModule: NetworkQualityNativeModule | null = NativeNetworkQuality ??
|
|
377
|
+
null,
|
|
378
|
+
appStateApi: NetworkQualityAppState = AppState
|
|
379
|
+
) {
|
|
380
|
+
this.nativeModule = nativeModule;
|
|
381
|
+
this.appStateApi = appStateApi;
|
|
382
|
+
this.appStateStatus = appStateApi.currentState;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Returns whether the native TurboModule is linked and available. */
|
|
386
|
+
public isSupported(): boolean {
|
|
387
|
+
return this.nativeModule !== null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Deep-merges and validates runtime monitoring and probe configuration. */
|
|
391
|
+
public configure(patch: DeepPartial<NetworkQualityConfig>): void {
|
|
392
|
+
const nativeModule = this.requireNativeModule();
|
|
393
|
+
const nextConfig = validateConfig(mergeConfig(this.config, patch));
|
|
394
|
+
this.config = cloneConfig(nextConfig);
|
|
395
|
+
this.reclassifyCachedSnapshot();
|
|
396
|
+
this.scheduleValidationGraceExpiry();
|
|
397
|
+
|
|
398
|
+
if (this.listeners.size > 0) {
|
|
399
|
+
this.refreshAutoProbeLifecycle();
|
|
400
|
+
nativeModule.startMonitoring(this.nativeMonitorOptions());
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Returns a defensive copy of the active runtime configuration. */
|
|
405
|
+
public getConfig(): NetworkQualityConfig {
|
|
406
|
+
this.requireNativeModule();
|
|
407
|
+
return cloneConfig(this.config);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Fetches, normalizes, classifies, and caches the current native snapshot. */
|
|
411
|
+
public getNetworkQuality(): Promise<NetworkQualityState> {
|
|
412
|
+
const nativeModule = this.requireNativeModule();
|
|
413
|
+
return nativeModule
|
|
414
|
+
.getCurrentState()
|
|
415
|
+
.then((snapshot) => this.acceptNativeSnapshot(snapshot));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Adds a live-state listener and starts native monitoring when necessary. */
|
|
419
|
+
public addNetworkQualityListener(
|
|
420
|
+
listener: (state: NetworkQualityState) => void
|
|
421
|
+
): RemovableSubscription {
|
|
422
|
+
const nativeModule = this.requireNativeModule();
|
|
423
|
+
if (this.listeners.size === 0) this.reclassifyCachedSnapshot();
|
|
424
|
+
const record: ListenerRecord = { active: true, listener };
|
|
425
|
+
const hadCachedState = this.cachedState !== null;
|
|
426
|
+
const versionAtSubscription = this.stateVersion;
|
|
427
|
+
this.listeners.add(record);
|
|
428
|
+
|
|
429
|
+
if (this.listeners.size === 1) {
|
|
430
|
+
try {
|
|
431
|
+
this.nativeSubscription = nativeModule.onNetworkStateChange(
|
|
432
|
+
(snapshot) => {
|
|
433
|
+
this.acceptNativeSnapshot(snapshot);
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
this.scheduleProbeExpiry();
|
|
437
|
+
this.scheduleValidationGraceExpiry();
|
|
438
|
+
this.refreshAutoProbeLifecycle();
|
|
439
|
+
nativeModule.startMonitoring(this.nativeMonitorOptions());
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.nativeSubscription?.remove();
|
|
442
|
+
this.nativeSubscription = null;
|
|
443
|
+
this.listeners.delete(record);
|
|
444
|
+
this.clearProbeExpiry();
|
|
445
|
+
this.clearValidationGraceExpiry();
|
|
446
|
+
this.stopAutoProbeLifecycle();
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (hadCachedState) {
|
|
452
|
+
queueMicrotask(() => {
|
|
453
|
+
if (
|
|
454
|
+
record.active &&
|
|
455
|
+
this.cachedState !== null &&
|
|
456
|
+
this.stateVersion === versionAtSubscription
|
|
457
|
+
) {
|
|
458
|
+
record.listener(this.cachedState);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
remove: () => {
|
|
465
|
+
if (!record.active) return;
|
|
466
|
+
record.active = false;
|
|
467
|
+
this.listeners.delete(record);
|
|
468
|
+
if (this.listeners.size === 0) {
|
|
469
|
+
this.nativeSubscription?.remove();
|
|
470
|
+
this.nativeSubscription = null;
|
|
471
|
+
this.clearProbeExpiry();
|
|
472
|
+
this.clearValidationGraceExpiry();
|
|
473
|
+
this.stopAutoProbeLifecycle();
|
|
474
|
+
this.monitoringStopVersion += 1;
|
|
475
|
+
nativeModule.stopMonitoring();
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Runs one active probe, de-duplicating concurrent calls. */
|
|
482
|
+
public probeNetwork(
|
|
483
|
+
options: Partial<ProbeConfig> = {}
|
|
484
|
+
): Promise<ProbeResult> {
|
|
485
|
+
const nativeModule = this.requireNativeModule();
|
|
486
|
+
if (this.inFlightProbe !== null) return this.inFlightProbe;
|
|
487
|
+
|
|
488
|
+
const probeConfig = { ...this.config.probe, ...options };
|
|
489
|
+
validateProbeConfig(probeConfig);
|
|
490
|
+
const nativeOptions: NativeProbeOptions = {
|
|
491
|
+
latencyUrl: probeConfig.latencyUrl,
|
|
492
|
+
downloadUrl: probeConfig.downloadUrl,
|
|
493
|
+
latencySamples: probeConfig.latencySamples,
|
|
494
|
+
timeoutMs: probeConfig.timeoutMs,
|
|
495
|
+
downloadMaxDurationMs: probeConfig.downloadMaxDurationMs,
|
|
496
|
+
downloadMaxBytes: probeConfig.downloadMaxBytes,
|
|
497
|
+
};
|
|
498
|
+
const stopVersionAtStart = this.monitoringStopVersion;
|
|
499
|
+
|
|
500
|
+
let transport: ProbeResult['transport'] = 'unknown';
|
|
501
|
+
let nativeProbe: Promise<NativeProbeResult>;
|
|
502
|
+
try {
|
|
503
|
+
if (this.latestSnapshot !== null) {
|
|
504
|
+
transport = this.latestSnapshot.transport;
|
|
505
|
+
nativeProbe = nativeModule.probe(nativeOptions);
|
|
506
|
+
} else {
|
|
507
|
+
nativeProbe = nativeModule.getCurrentState().then((snapshot) => {
|
|
508
|
+
if (this.monitoringStopVersion !== stopVersionAtStart) {
|
|
509
|
+
throw new NetworkQualityError(
|
|
510
|
+
'E_PROBE_FAILED',
|
|
511
|
+
'The network probe was cancelled because monitoring stopped.'
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
transport = this.acceptNativeSnapshot(snapshot).transport;
|
|
515
|
+
return nativeModule.probe(nativeOptions);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
} catch (error) {
|
|
519
|
+
const wrapped = wrapProbeError(error);
|
|
520
|
+
this.recordProbeFailure(wrapped, transport, probeConfig.resultTtlMs);
|
|
521
|
+
throw wrapped;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let managedProbe: Promise<ProbeResult>;
|
|
525
|
+
managedProbe = nativeProbe
|
|
526
|
+
.then((result) => {
|
|
527
|
+
const probeResult: ProbeResult = { ...result, transport };
|
|
528
|
+
this.lastProbe = probeResult;
|
|
529
|
+
this.lastProbeFailure = null;
|
|
530
|
+
this.lastProbeFailureTtlMs = null;
|
|
531
|
+
this.lastProbeResultTtlMs = probeConfig.resultTtlMs;
|
|
532
|
+
this.reclassifyCachedSnapshot();
|
|
533
|
+
this.scheduleProbeExpiry();
|
|
534
|
+
return probeResult;
|
|
535
|
+
})
|
|
536
|
+
.catch((error: unknown) => {
|
|
537
|
+
const wrapped = wrapProbeError(error);
|
|
538
|
+
this.recordProbeFailure(wrapped, transport, probeConfig.resultTtlMs);
|
|
539
|
+
throw wrapped;
|
|
540
|
+
})
|
|
541
|
+
.finally(() => {
|
|
542
|
+
if (this.inFlightProbe === managedProbe) this.inFlightProbe = null;
|
|
543
|
+
});
|
|
544
|
+
this.inFlightProbe = managedProbe;
|
|
545
|
+
return managedProbe;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Returns the most recent successful active-probe result. */
|
|
549
|
+
public getLastProbeResult(): ProbeResult | null {
|
|
550
|
+
this.requireNativeModule();
|
|
551
|
+
return this.lastProbe;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Returns the referentially stable state snapshot used by React. */
|
|
555
|
+
public getCachedState(): NetworkQualityState | null {
|
|
556
|
+
return this.cachedState;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private requireNativeModule(): NetworkQualityNativeModule {
|
|
560
|
+
if (this.nativeModule === null) throw unsupportedError();
|
|
561
|
+
return this.nativeModule;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private nativeMonitorOptions(): NativeMonitorOptions {
|
|
565
|
+
return {
|
|
566
|
+
throttleMs: this.config.throttleMs,
|
|
567
|
+
bandwidthChangeThresholdPct: this.config.bandwidthChangeThresholdPct,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private acceptNativeSnapshot(
|
|
572
|
+
nativeSnapshot: NativeNetworkSnapshot
|
|
573
|
+
): NetworkQualityState {
|
|
574
|
+
const snapshot = normalizeSnapshot(nativeSnapshot);
|
|
575
|
+
const previousSnapshot = this.latestSnapshot;
|
|
576
|
+
const previousTransport = previousSnapshot?.transport;
|
|
577
|
+
const networkChanged =
|
|
578
|
+
previousSnapshot === null ||
|
|
579
|
+
previousSnapshot.isConnected !== snapshot.isConnected ||
|
|
580
|
+
previousSnapshot.transport !== snapshot.transport;
|
|
581
|
+
const transportChanged =
|
|
582
|
+
previousSnapshot !== null &&
|
|
583
|
+
previousSnapshot.transport !== snapshot.transport;
|
|
584
|
+
if (networkChanged) this.networkChangedAt = Date.now();
|
|
585
|
+
if (transportChanged) {
|
|
586
|
+
this.lastProbeFailure = null;
|
|
587
|
+
this.lastProbeFailureTtlMs = null;
|
|
588
|
+
}
|
|
589
|
+
this.latestSnapshot = snapshot;
|
|
590
|
+
const state = this.buildState(snapshot);
|
|
591
|
+
const acceptedState = this.acceptState(state);
|
|
592
|
+
this.scheduleProbeExpiry();
|
|
593
|
+
this.scheduleValidationGraceExpiry();
|
|
594
|
+
|
|
595
|
+
if (
|
|
596
|
+
previousTransport !== undefined &&
|
|
597
|
+
previousTransport !== snapshot.transport
|
|
598
|
+
) {
|
|
599
|
+
this.scheduleTransportChangeProbe();
|
|
600
|
+
}
|
|
601
|
+
return acceptedState;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private buildState(snapshot: NetworkSnapshot): NetworkQualityState {
|
|
605
|
+
const classifierConfig =
|
|
606
|
+
this.lastProbeResultTtlMs === null
|
|
607
|
+
? this.config
|
|
608
|
+
: {
|
|
609
|
+
...this.config,
|
|
610
|
+
probe: {
|
|
611
|
+
...this.config.probe,
|
|
612
|
+
resultTtlMs: this.lastProbeResultTtlMs,
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
return {
|
|
616
|
+
...snapshot,
|
|
617
|
+
...classifyNetworkQuality(
|
|
618
|
+
snapshot,
|
|
619
|
+
this.lastProbe,
|
|
620
|
+
classifierConfig,
|
|
621
|
+
Date.now(),
|
|
622
|
+
{
|
|
623
|
+
lastProbeFailure: this.lastProbeFailure,
|
|
624
|
+
networkChangedAt: this.networkChangedAt,
|
|
625
|
+
probeResultTtlMs: this.lastProbeResultTtlMs ?? undefined,
|
|
626
|
+
probeFailureTtlMs: this.lastProbeFailureTtlMs ?? undefined,
|
|
627
|
+
}
|
|
628
|
+
),
|
|
629
|
+
lastProbe: this.lastProbe,
|
|
630
|
+
lastProbeFailure: this.lastProbeFailure,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private acceptState(state: NetworkQualityState): NetworkQualityState {
|
|
635
|
+
if (
|
|
636
|
+
this.cachedState !== null &&
|
|
637
|
+
stateEqualsIgnoringSnapshotTimestamp(this.cachedState, state)
|
|
638
|
+
) {
|
|
639
|
+
return this.cachedState;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
this.cachedState = state;
|
|
643
|
+
this.stateVersion += 1;
|
|
644
|
+
for (const record of [...this.listeners]) {
|
|
645
|
+
if (record.active) record.listener(state);
|
|
646
|
+
}
|
|
647
|
+
return state;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private reclassifyCachedSnapshot(): void {
|
|
651
|
+
if (this.latestSnapshot !== null) {
|
|
652
|
+
this.acceptState(this.buildState(this.latestSnapshot));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private scheduleProbeExpiry(): void {
|
|
657
|
+
this.clearProbeExpiry();
|
|
658
|
+
const snapshot = this.latestSnapshot;
|
|
659
|
+
if (this.listeners.size === 0 || snapshot === null) return;
|
|
660
|
+
|
|
661
|
+
const expirations: number[] = [];
|
|
662
|
+
if (
|
|
663
|
+
this.lastProbe !== null &&
|
|
664
|
+
this.lastProbeResultTtlMs !== null &&
|
|
665
|
+
this.lastProbe.transport === snapshot.transport
|
|
666
|
+
) {
|
|
667
|
+
expirations.push(
|
|
668
|
+
this.lastProbe.timestamp + this.lastProbeResultTtlMs + 1
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
if (
|
|
672
|
+
this.lastProbeFailure !== null &&
|
|
673
|
+
this.lastProbeFailureTtlMs !== null &&
|
|
674
|
+
this.lastProbeFailure.transport === snapshot.transport
|
|
675
|
+
) {
|
|
676
|
+
expirations.push(
|
|
677
|
+
this.lastProbeFailure.timestamp + this.lastProbeFailureTtlMs + 1
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
const futureExpirations = expirations.filter(
|
|
682
|
+
(expiration) => expiration > now
|
|
683
|
+
);
|
|
684
|
+
if (futureExpirations.length === 0) return;
|
|
685
|
+
const remainingMs = Math.min(...futureExpirations) - now;
|
|
686
|
+
const delayMs = Math.min(remainingMs, MAX_TIMER_DELAY_MS);
|
|
687
|
+
this.probeExpiry = setTimeout(() => {
|
|
688
|
+
this.probeExpiry = null;
|
|
689
|
+
this.reclassifyCachedSnapshot();
|
|
690
|
+
this.scheduleProbeExpiry();
|
|
691
|
+
}, delayMs);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private clearProbeExpiry(): void {
|
|
695
|
+
if (this.probeExpiry !== null) {
|
|
696
|
+
clearTimeout(this.probeExpiry);
|
|
697
|
+
this.probeExpiry = null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private scheduleValidationGraceExpiry(): void {
|
|
702
|
+
this.clearValidationGraceExpiry();
|
|
703
|
+
const snapshot = this.latestSnapshot;
|
|
704
|
+
const networkChangedAt = this.networkChangedAt;
|
|
705
|
+
if (
|
|
706
|
+
snapshot === null ||
|
|
707
|
+
networkChangedAt === null ||
|
|
708
|
+
!snapshot.isConnected ||
|
|
709
|
+
snapshot.isValidated !== false ||
|
|
710
|
+
snapshot.isCaptivePortal === true
|
|
711
|
+
) {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const remainingMs =
|
|
716
|
+
networkChangedAt + this.config.validationGraceMs - Date.now();
|
|
717
|
+
if (remainingMs <= 0) return;
|
|
718
|
+
this.validationGraceExpiry = setTimeout(
|
|
719
|
+
() => {
|
|
720
|
+
this.validationGraceExpiry = null;
|
|
721
|
+
this.reclassifyCachedSnapshot();
|
|
722
|
+
},
|
|
723
|
+
Math.min(remainingMs, MAX_TIMER_DELAY_MS)
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private clearValidationGraceExpiry(): void {
|
|
728
|
+
if (this.validationGraceExpiry !== null) {
|
|
729
|
+
clearTimeout(this.validationGraceExpiry);
|
|
730
|
+
this.validationGraceExpiry = null;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private recordProbeFailure(
|
|
735
|
+
error: NetworkQualityError,
|
|
736
|
+
transport: ProbeFailure['transport'],
|
|
737
|
+
resultTtlMs: number
|
|
738
|
+
): void {
|
|
739
|
+
if (error.code !== 'E_PROBE_FAILED' && error.code !== 'E_PROBE_TIMEOUT') {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
this.lastProbeFailure = {
|
|
743
|
+
code: error.code,
|
|
744
|
+
message: error.message,
|
|
745
|
+
transport,
|
|
746
|
+
timestamp: Date.now(),
|
|
747
|
+
};
|
|
748
|
+
this.lastProbeFailureTtlMs = resultTtlMs;
|
|
749
|
+
this.reclassifyCachedSnapshot();
|
|
750
|
+
this.scheduleProbeExpiry();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private refreshAutoProbeLifecycle(): void {
|
|
754
|
+
this.stopAutoProbeLifecycle();
|
|
755
|
+
if (!this.config.autoProbe.enabled || this.listeners.size === 0) return;
|
|
756
|
+
|
|
757
|
+
this.appStateStatus = this.appStateApi.currentState;
|
|
758
|
+
this.appStateSubscription = this.appStateApi.addEventListener(
|
|
759
|
+
'change',
|
|
760
|
+
(state) => {
|
|
761
|
+
this.appStateStatus = state;
|
|
762
|
+
this.clearAutoProbeInterval();
|
|
763
|
+
if (state !== 'active') {
|
|
764
|
+
this.clearTransportDebounce();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
this.startAutoProbeInterval();
|
|
768
|
+
}
|
|
769
|
+
);
|
|
770
|
+
if (this.appStateStatus === 'active') this.startAutoProbeInterval();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private stopAutoProbeLifecycle(): void {
|
|
774
|
+
this.clearAutoProbeInterval();
|
|
775
|
+
this.clearTransportDebounce();
|
|
776
|
+
this.appStateSubscription?.remove();
|
|
777
|
+
this.appStateSubscription = null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private startAutoProbeInterval(): void {
|
|
781
|
+
if (this.autoProbeInterval !== null) return;
|
|
782
|
+
this.autoProbeInterval = setInterval(() => {
|
|
783
|
+
this.runAutoProbe();
|
|
784
|
+
}, this.config.autoProbe.intervalMs);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private clearAutoProbeInterval(): void {
|
|
788
|
+
if (this.autoProbeInterval !== null) {
|
|
789
|
+
clearInterval(this.autoProbeInterval);
|
|
790
|
+
this.autoProbeInterval = null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private clearTransportDebounce(): void {
|
|
795
|
+
if (this.transportDebounce !== null) {
|
|
796
|
+
clearTimeout(this.transportDebounce);
|
|
797
|
+
this.transportDebounce = null;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private scheduleTransportChangeProbe(): void {
|
|
802
|
+
if (
|
|
803
|
+
!this.config.autoProbe.enabled ||
|
|
804
|
+
!this.config.autoProbe.onTransportChange ||
|
|
805
|
+
this.listeners.size === 0 ||
|
|
806
|
+
this.appStateStatus !== 'active'
|
|
807
|
+
) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
this.clearTransportDebounce();
|
|
812
|
+
this.transportDebounce = setTimeout(() => {
|
|
813
|
+
this.transportDebounce = null;
|
|
814
|
+
this.runAutoProbe();
|
|
815
|
+
}, TRANSPORT_CHANGE_DEBOUNCE_MS);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private runAutoProbe(): void {
|
|
819
|
+
const snapshot = this.latestSnapshot;
|
|
820
|
+
if (
|
|
821
|
+
!this.config.autoProbe.enabled ||
|
|
822
|
+
this.listeners.size === 0 ||
|
|
823
|
+
this.appStateStatus !== 'active' ||
|
|
824
|
+
snapshot === null ||
|
|
825
|
+
!snapshot.isConnected ||
|
|
826
|
+
(snapshot.isExpensive && !this.config.autoProbe.allowOnExpensive) ||
|
|
827
|
+
(snapshot.isConstrained && !this.config.autoProbe.allowOnConstrained)
|
|
828
|
+
) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
this.probeNetwork().catch(() => {
|
|
834
|
+
// Automatic probes are best-effort and intentionally silent.
|
|
835
|
+
});
|
|
836
|
+
} catch {
|
|
837
|
+
// Synchronous validation/native errors are also silent for auto-probes.
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const manager = new NetworkQualityManager();
|
|
843
|
+
|
|
844
|
+
/** Internal render-time support assertion used by the React hooks. */
|
|
845
|
+
export function assertNetworkQualitySupported(): void {
|
|
846
|
+
if (!manager.isSupported()) throw unsupportedError();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Returns whether the native TurboModule is linked and available. */
|
|
850
|
+
export function isSupported(): boolean {
|
|
851
|
+
return manager.isSupported();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Deep-merges runtime options into the current configuration. */
|
|
855
|
+
export function configure(config: DeepPartial<NetworkQualityConfig>): void {
|
|
856
|
+
manager.configure(config);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Returns a defensive copy of the current configuration. */
|
|
860
|
+
export function getConfig(): NetworkQualityConfig {
|
|
861
|
+
return manager.getConfig();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Fetches the current network-quality state. */
|
|
865
|
+
export function getNetworkQuality(): Promise<NetworkQualityState> {
|
|
866
|
+
return manager.getNetworkQuality();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Subscribes to live network-quality state changes. */
|
|
870
|
+
export function addNetworkQualityListener(
|
|
871
|
+
listener: (state: NetworkQualityState) => void
|
|
872
|
+
): RemovableSubscription {
|
|
873
|
+
return manager.addNetworkQualityListener(listener);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Runs an active latency and optional throughput probe. */
|
|
877
|
+
export function probeNetwork(
|
|
878
|
+
options?: Partial<ProbeConfig>
|
|
879
|
+
): Promise<ProbeResult> {
|
|
880
|
+
return manager.probeNetwork(options);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Returns the most recent successful active-probe result. */
|
|
884
|
+
export function getLastProbeResult(): ProbeResult | null {
|
|
885
|
+
return manager.getLastProbeResult();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Internal stable snapshot getter used by `useSyncExternalStore`. */
|
|
889
|
+
export function getCachedNetworkQualityState(): NetworkQualityState | null {
|
|
890
|
+
return manager.getCachedState();
|
|
891
|
+
}
|