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.
Files changed (72) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +899 -0
  3. package/android/build.gradle +60 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/rnnetworkquality/CellularInfo.kt +34 -0
  6. package/android/src/main/java/com/rnnetworkquality/DownloadPolicy.kt +26 -0
  7. package/android/src/main/java/com/rnnetworkquality/Mappers.kt +106 -0
  8. package/android/src/main/java/com/rnnetworkquality/NetworkMonitor.kt +263 -0
  9. package/android/src/main/java/com/rnnetworkquality/NetworkProbe.kt +668 -0
  10. package/android/src/main/java/com/rnnetworkquality/NetworkQualityModule.kt +287 -0
  11. package/android/src/main/java/com/rnnetworkquality/NetworkQualityPackage.kt +25 -0
  12. package/android/src/main/java/com/rnnetworkquality/NetworkSnapshot.kt +83 -0
  13. package/android/src/main/java/com/rnnetworkquality/SnapshotBuilder.kt +92 -0
  14. package/android/src/main/java/com/rnnetworkquality/Throttler.kt +100 -0
  15. package/ios/CellularInfo.swift +47 -0
  16. package/ios/NetworkProbe.swift +391 -0
  17. package/ios/NetworkQuality.h +8 -0
  18. package/ios/NetworkQuality.mm +88 -0
  19. package/ios/NetworkQualityImpl.swift +371 -0
  20. package/ios/PathSnapshot.swift +109 -0
  21. package/ios/PrivacyInfo.xcprivacy +23 -0
  22. package/ios/Throttler.swift +174 -0
  23. package/lib/module/NativeNetworkQuality.js +5 -0
  24. package/lib/module/NativeNetworkQuality.js.map +1 -0
  25. package/lib/module/classify.js +126 -0
  26. package/lib/module/classify.js.map +1 -0
  27. package/lib/module/constants.js +52 -0
  28. package/lib/module/constants.js.map +1 -0
  29. package/lib/module/errors.js +18 -0
  30. package/lib/module/errors.js.map +1 -0
  31. package/lib/module/hooks.js +78 -0
  32. package/lib/module/hooks.js.map +1 -0
  33. package/lib/module/index.js +19 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/manager.js +595 -0
  36. package/lib/module/manager.js.map +1 -0
  37. package/lib/module/normalize.js +35 -0
  38. package/lib/module/normalize.js.map +1 -0
  39. package/lib/module/package.json +1 -0
  40. package/lib/module/types.js +2 -0
  41. package/lib/module/types.js.map +1 -0
  42. package/lib/typescript/package.json +1 -0
  43. package/lib/typescript/src/NativeNetworkQuality.d.ts +50 -0
  44. package/lib/typescript/src/NativeNetworkQuality.d.ts.map +1 -0
  45. package/lib/typescript/src/classify.d.ts +12 -0
  46. package/lib/typescript/src/classify.d.ts.map +1 -0
  47. package/lib/typescript/src/constants.d.ts +15 -0
  48. package/lib/typescript/src/constants.d.ts.map +1 -0
  49. package/lib/typescript/src/errors.d.ts +13 -0
  50. package/lib/typescript/src/errors.d.ts.map +1 -0
  51. package/lib/typescript/src/hooks.d.ts +18 -0
  52. package/lib/typescript/src/hooks.d.ts.map +1 -0
  53. package/lib/typescript/src/index.d.ts +13 -0
  54. package/lib/typescript/src/index.d.ts.map +1 -0
  55. package/lib/typescript/src/manager.d.ts +98 -0
  56. package/lib/typescript/src/manager.d.ts.map +1 -0
  57. package/lib/typescript/src/normalize.d.ts +5 -0
  58. package/lib/typescript/src/normalize.d.ts.map +1 -0
  59. package/lib/typescript/src/types.d.ts +171 -0
  60. package/lib/typescript/src/types.d.ts.map +1 -0
  61. package/mock.js +318 -0
  62. package/package.json +161 -0
  63. package/rn-network-quality.podspec +32 -0
  64. package/src/NativeNetworkQuality.ts +58 -0
  65. package/src/classify.ts +208 -0
  66. package/src/constants.ts +48 -0
  67. package/src/errors.ts +23 -0
  68. package/src/hooks.ts +110 -0
  69. package/src/index.tsx +25 -0
  70. package/src/manager.ts +891 -0
  71. package/src/normalize.ts +81 -0
  72. package/src/types.ts +207 -0
@@ -0,0 +1,208 @@
1
+ import { DEFAULT_CONFIG, QUALITY_ORDER } from './constants';
2
+ import type {
3
+ NetworkQuality,
4
+ NetworkQualityClassificationContext,
5
+ NetworkQualityConfig,
6
+ NetworkQualityState,
7
+ NetworkSnapshot,
8
+ ProbeResult,
9
+ QualitySource,
10
+ QualityThresholds,
11
+ } from './types';
12
+
13
+ type Classification = Pick<
14
+ NetworkQualityState,
15
+ | 'quality'
16
+ | 'qualitySource'
17
+ | 'effectiveDownlinkKbps'
18
+ | 'effectiveRttMs'
19
+ | 'reasons'
20
+ >;
21
+
22
+ type ClassifierConfig = Pick<NetworkQualityConfig, 'thresholds' | 'probe'> &
23
+ Partial<Pick<NetworkQualityConfig, 'validationGraceMs'>>;
24
+ type MetricQuality = Exclude<NetworkQuality, 'unknown' | 'offline'>;
25
+
26
+ function formatValue(value: number): string {
27
+ return Number.isInteger(value) ? String(value) : value.toFixed(1);
28
+ }
29
+
30
+ function downlinkTier(
31
+ value: number,
32
+ thresholds: QualityThresholds
33
+ ): MetricQuality {
34
+ if (value >= thresholds.excellent.minDownlinkKbps) return 'excellent';
35
+ if (value >= thresholds.good.minDownlinkKbps) return 'good';
36
+ if (value >= thresholds.moderate.minDownlinkKbps) return 'moderate';
37
+ return 'poor';
38
+ }
39
+
40
+ function rttTier(value: number, thresholds: QualityThresholds): MetricQuality {
41
+ if (value <= thresholds.excellent.maxRttMs) return 'excellent';
42
+ if (value <= thresholds.good.maxRttMs) return 'good';
43
+ if (value <= thresholds.moderate.maxRttMs) return 'moderate';
44
+ return 'poor';
45
+ }
46
+
47
+ function worseQuality(
48
+ first: MetricQuality,
49
+ second: MetricQuality
50
+ ): MetricQuality {
51
+ const firstIndex = QUALITY_ORDER.indexOf(first);
52
+ const secondIndex = QUALITY_ORDER.indexOf(second);
53
+ return firstIndex <= secondIndex ? first : second;
54
+ }
55
+
56
+ function forcedClassification(
57
+ quality: 'unknown' | 'offline' | 'poor',
58
+ reason: string,
59
+ qualitySource: QualitySource = 'none'
60
+ ): Classification {
61
+ return {
62
+ quality,
63
+ qualitySource,
64
+ effectiveDownlinkKbps: null,
65
+ effectiveRttMs: null,
66
+ reasons: [reason],
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Classifies a snapshot using a fresh active probe, an OS estimate, or a
72
+ * cellular-generation heuristic. The function is pure and deterministic.
73
+ */
74
+ export function classifyNetworkQuality(
75
+ snapshot: NetworkSnapshot,
76
+ probe: ProbeResult | null,
77
+ config: ClassifierConfig = DEFAULT_CONFIG,
78
+ now: number = Date.now(),
79
+ context: NetworkQualityClassificationContext = {}
80
+ ): Classification {
81
+ if (!snapshot.isConnected) {
82
+ return forcedClassification('offline', 'not-connected');
83
+ }
84
+ if (snapshot.isCaptivePortal === true) {
85
+ return forcedClassification('poor', 'captive-portal');
86
+ }
87
+
88
+ const failure = context.lastProbeFailure ?? null;
89
+ const failureTtlMs = context.probeFailureTtlMs ?? config.probe.resultTtlMs;
90
+ if (
91
+ failure !== null &&
92
+ failure.transport === snapshot.transport &&
93
+ now - failure.timestamp <= failureTtlMs &&
94
+ (probe === null || failure.timestamp > probe.timestamp)
95
+ ) {
96
+ return forcedClassification(
97
+ 'poor',
98
+ `probe failed: ${failure.code}`,
99
+ 'probe'
100
+ );
101
+ }
102
+
103
+ if (snapshot.isValidated === false) {
104
+ const networkChangedAt = context.networkChangedAt ?? null;
105
+ const validationGraceMs =
106
+ config.validationGraceMs ?? DEFAULT_CONFIG.validationGraceMs;
107
+ if (
108
+ networkChangedAt !== null &&
109
+ now - networkChangedAt < validationGraceMs
110
+ ) {
111
+ return forcedClassification('unknown', 'validating');
112
+ }
113
+ return forcedClassification('poor', 'not-validated');
114
+ }
115
+
116
+ const reasons: string[] = [];
117
+ let freshProbe: ProbeResult | null = null;
118
+ if (probe !== null) {
119
+ if (probe.transport !== snapshot.transport) {
120
+ reasons.push('probe stale (transport changed)');
121
+ } else if (
122
+ now - probe.timestamp >
123
+ (context.probeResultTtlMs ?? config.probe.resultTtlMs)
124
+ ) {
125
+ reasons.push('probe stale (expired)');
126
+ } else {
127
+ freshProbe = probe;
128
+ }
129
+ }
130
+
131
+ const usedProbeDownlink = freshProbe?.downlinkKbps != null;
132
+ const usedProbeRtt = freshProbe?.rttMs != null;
133
+ const usedOsDownlink = !usedProbeDownlink && snapshot.downlinkKbps !== null;
134
+ const effectiveDownlinkKbps =
135
+ freshProbe?.downlinkKbps ?? snapshot.downlinkKbps ?? null;
136
+ const effectiveRttMs = freshProbe?.rttMs ?? null;
137
+
138
+ let qualitySource: QualitySource = 'none';
139
+ if (usedProbeDownlink || usedProbeRtt) {
140
+ qualitySource = 'probe';
141
+ } else if (usedOsDownlink) {
142
+ qualitySource = 'os-estimate';
143
+ }
144
+
145
+ let quality: MetricQuality | null = null;
146
+ if (effectiveDownlinkKbps !== null) {
147
+ const tier = downlinkTier(effectiveDownlinkKbps, config.thresholds);
148
+ reasons.push(
149
+ `downlink ${formatValue(effectiveDownlinkKbps)} kbps → ${tier}`
150
+ );
151
+ quality = tier;
152
+ }
153
+ if (effectiveRttMs !== null) {
154
+ const tier = rttTier(effectiveRttMs, config.thresholds);
155
+ reasons.push(`rtt ${formatValue(effectiveRttMs)} ms → ${tier}`);
156
+ quality = quality === null ? tier : worseQuality(quality, tier);
157
+ }
158
+
159
+ if (quality !== null) {
160
+ return {
161
+ quality,
162
+ qualitySource,
163
+ effectiveDownlinkKbps,
164
+ effectiveRttMs,
165
+ reasons,
166
+ };
167
+ }
168
+
169
+ const heuristicQuality =
170
+ snapshot.cellularGeneration === '5g' || snapshot.cellularGeneration === '4g'
171
+ ? 'good'
172
+ : snapshot.cellularGeneration === '3g'
173
+ ? 'moderate'
174
+ : snapshot.cellularGeneration === '2g'
175
+ ? 'poor'
176
+ : null;
177
+
178
+ if (heuristicQuality !== null) {
179
+ reasons.push(
180
+ `cellular generation ${snapshot.cellularGeneration} → ${heuristicQuality}`
181
+ );
182
+ return {
183
+ quality: heuristicQuality,
184
+ qualitySource: 'heuristic',
185
+ effectiveDownlinkKbps: null,
186
+ effectiveRttMs: null,
187
+ reasons,
188
+ };
189
+ }
190
+
191
+ reasons.push('no quality metrics available');
192
+ return {
193
+ quality: 'unknown',
194
+ qualitySource: 'none',
195
+ effectiveDownlinkKbps: null,
196
+ effectiveRttMs: null,
197
+ reasons,
198
+ };
199
+ }
200
+
201
+ /** Returns whether a ranked quality tier meets or exceeds a minimum tier. */
202
+ export function isQualityAtLeast(
203
+ quality: NetworkQuality,
204
+ minimum: NetworkQuality
205
+ ): boolean {
206
+ if (quality === 'unknown' || minimum === 'unknown') return false;
207
+ return QUALITY_ORDER.indexOf(quality) >= QUALITY_ORDER.indexOf(minimum);
208
+ }
@@ -0,0 +1,48 @@
1
+ import type { NetworkQuality, NetworkQualityConfig } from './types';
2
+
3
+ /** Default monitoring, classifier, and probe configuration. */
4
+ export const DEFAULT_CONFIG: NetworkQualityConfig = {
5
+ throttleMs: 1_000,
6
+ bandwidthChangeThresholdPct: 10,
7
+ validationGraceMs: 10_000,
8
+ thresholds: {
9
+ excellent: { minDownlinkKbps: 20_000, maxRttMs: 50 },
10
+ good: { minDownlinkKbps: 5_000, maxRttMs: 150 },
11
+ moderate: { minDownlinkKbps: 1_000, maxRttMs: 400 },
12
+ },
13
+ probe: {
14
+ latencyUrl: 'https://www.gstatic.com/generate_204',
15
+ downloadUrl: 'https://speed.cloudflare.com/__down?bytes=3000000',
16
+ latencySamples: 3,
17
+ timeoutMs: 8_000,
18
+ downloadMaxDurationMs: 3_000,
19
+ downloadMaxBytes: 3_000_000,
20
+ resultTtlMs: 60_000,
21
+ },
22
+ autoProbe: {
23
+ enabled: false,
24
+ intervalMs: 60_000,
25
+ onTransportChange: true,
26
+ allowOnExpensive: false,
27
+ allowOnConstrained: false,
28
+ },
29
+ };
30
+
31
+ /** Ranked quality tiers from worst to best. `unknown` is intentionally unranked. */
32
+ export const QUALITY_ORDER: NetworkQuality[] = [
33
+ 'offline',
34
+ 'poor',
35
+ 'moderate',
36
+ 'good',
37
+ 'excellent',
38
+ ];
39
+
40
+ /** Error codes that can be returned by the package. */
41
+ export const ERROR_CODES = {
42
+ unsupported: 'E_UNSUPPORTED',
43
+ offline: 'E_OFFLINE',
44
+ invalidUrl: 'E_INVALID_URL',
45
+ probeTimeout: 'E_PROBE_TIMEOUT',
46
+ probeFailed: 'E_PROBE_FAILED',
47
+ probeSkipped: 'E_PROBE_SKIPPED',
48
+ } as const;
package/src/errors.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { NetworkQualityErrorCode } from './types';
2
+
3
+ /** Error raised by monitoring and probing operations. */
4
+ export class NetworkQualityError extends Error {
5
+ /** Stable machine-readable error code. */
6
+ public readonly code: NetworkQualityErrorCode;
7
+
8
+ /** Original platform error, when one is available. */
9
+ public override readonly cause?: unknown;
10
+
11
+ /** Creates a network-quality error with a stable code and helpful message. */
12
+ public constructor(
13
+ code: NetworkQualityErrorCode,
14
+ message: string,
15
+ options?: { cause?: unknown }
16
+ ) {
17
+ super(message);
18
+ this.name = 'NetworkQualityError';
19
+ this.code = code;
20
+ this.cause = options?.cause;
21
+ Object.setPrototypeOf(this, new.target.prototype);
22
+ }
23
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,110 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ useSyncExternalStore,
7
+ } from 'react';
8
+
9
+ import { NetworkQualityError } from './errors';
10
+ import {
11
+ addNetworkQualityListener,
12
+ assertNetworkQualitySupported,
13
+ getCachedNetworkQualityState,
14
+ probeNetwork,
15
+ } from './manager';
16
+ import type { NetworkQualityState, ProbeConfig, ProbeResult } from './types';
17
+
18
+ function subscribe(onStoreChange: () => void): () => void {
19
+ const subscription = addNetworkQualityListener(() => {
20
+ onStoreChange();
21
+ });
22
+ return () => subscription.remove();
23
+ }
24
+
25
+ function getServerSnapshot(): null {
26
+ return null;
27
+ }
28
+
29
+ /** Subscribes a React component to the referentially stable live state. */
30
+ export function useNetworkQuality(): NetworkQualityState | null {
31
+ const state = useSyncExternalStore(
32
+ subscribe,
33
+ getCachedNetworkQualityState,
34
+ getServerSnapshot
35
+ );
36
+ assertNetworkQualitySupported();
37
+ return state;
38
+ }
39
+
40
+ /** State and trigger returned by `useNetworkProbe`. */
41
+ export interface UseNetworkProbeResult {
42
+ /** Starts a de-duplicated active network probe. */
43
+ probe: (options?: Partial<ProbeConfig>) => Promise<ProbeResult>;
44
+ /** Whether this hook's most recent request is still running. */
45
+ isProbing: boolean;
46
+ /** Most recent successful result started by this hook. */
47
+ result: ProbeResult | null;
48
+ /** Most recent probe failure started by this hook. */
49
+ error: NetworkQualityError | null;
50
+ }
51
+
52
+ function asNetworkQualityError(error: unknown): NetworkQualityError {
53
+ return error instanceof NetworkQualityError
54
+ ? error
55
+ : new NetworkQualityError(
56
+ 'E_PROBE_FAILED',
57
+ error instanceof Error ? error.message : 'The network probe failed',
58
+ { cause: error }
59
+ );
60
+ }
61
+
62
+ /** Provides imperative probing with React-friendly loading and error state. */
63
+ export function useNetworkProbe(): UseNetworkProbeResult {
64
+ const mounted = useRef(true);
65
+ const requestId = useRef(0);
66
+ const [isProbing, setIsProbing] = useState(false);
67
+ const [result, setResult] = useState<ProbeResult | null>(null);
68
+ const [error, setError] = useState<NetworkQualityError | null>(null);
69
+
70
+ useEffect(() => {
71
+ mounted.current = true;
72
+ return () => {
73
+ mounted.current = false;
74
+ requestId.current += 1;
75
+ };
76
+ }, []);
77
+
78
+ const probe = useCallback(
79
+ async (options?: Partial<ProbeConfig>): Promise<ProbeResult> => {
80
+ const currentRequest = requestId.current + 1;
81
+ requestId.current = currentRequest;
82
+ if (mounted.current) {
83
+ setIsProbing(true);
84
+ setError(null);
85
+ }
86
+
87
+ try {
88
+ const nextResult = await probeNetwork(options);
89
+ if (mounted.current && requestId.current === currentRequest) {
90
+ setResult(nextResult);
91
+ }
92
+ return nextResult;
93
+ } catch (caught) {
94
+ const nextError = asNetworkQualityError(caught);
95
+ if (mounted.current && requestId.current === currentRequest) {
96
+ setError(nextError);
97
+ }
98
+ throw nextError;
99
+ } finally {
100
+ if (mounted.current && requestId.current === currentRequest) {
101
+ setIsProbing(false);
102
+ }
103
+ }
104
+ },
105
+ []
106
+ );
107
+
108
+ assertNetworkQualitySupported();
109
+ return { probe, isProbing, result, error };
110
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,25 @@
1
+ /** Purely classifies a network snapshot and optional active-probe result. */
2
+ export { classifyNetworkQuality, isQualityAtLeast } from './classify';
3
+
4
+ /** Default configuration and ranked quality tiers. */
5
+ export { DEFAULT_CONFIG, QUALITY_ORDER } from './constants';
6
+
7
+ /** Error type used by all package operations. */
8
+ export { NetworkQualityError } from './errors';
9
+
10
+ /** React hooks for live state and on-demand active probes. */
11
+ export { useNetworkProbe, useNetworkQuality } from './hooks';
12
+
13
+ /** Runtime configuration, snapshots, listeners, and active probing. */
14
+ export {
15
+ addNetworkQualityListener,
16
+ configure,
17
+ getConfig,
18
+ getLastProbeResult,
19
+ getNetworkQuality,
20
+ isSupported,
21
+ probeNetwork,
22
+ } from './manager';
23
+
24
+ /** Public network-quality types. */
25
+ export type * from './types';