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,287 @@
|
|
|
1
|
+
package com.rnnetworkquality
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.HandlerThread
|
|
7
|
+
import android.util.Log
|
|
8
|
+
import com.facebook.react.bridge.Promise
|
|
9
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
10
|
+
import com.facebook.react.bridge.ReadableMap
|
|
11
|
+
|
|
12
|
+
internal class NetworkQualityModule(
|
|
13
|
+
private val reactContext: ReactApplicationContext,
|
|
14
|
+
) : NativeNetworkQualitySpec(reactContext) {
|
|
15
|
+
private val lifecycleLock = Any()
|
|
16
|
+
private val connectivityManager =
|
|
17
|
+
reactContext.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
18
|
+
private val snapshotBuilder = SnapshotBuilder(
|
|
19
|
+
connectivityManager,
|
|
20
|
+
CellularInfo(reactContext),
|
|
21
|
+
)
|
|
22
|
+
private val networkProbe = NetworkProbe(reactContext)
|
|
23
|
+
|
|
24
|
+
@Volatile
|
|
25
|
+
private var invalidated = false
|
|
26
|
+
private var nextGeneration = 0L
|
|
27
|
+
private var worker: MonitorRuntime? = null
|
|
28
|
+
|
|
29
|
+
override fun getName(): String = NAME
|
|
30
|
+
|
|
31
|
+
override fun getCurrentState(promise: Promise) {
|
|
32
|
+
if (invalidated) {
|
|
33
|
+
promise.reject(ERROR_STATE_FAILED, "NetworkQuality has been invalidated")
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
val runtime = synchronized(lifecycleLock) {
|
|
38
|
+
if (invalidated) {
|
|
39
|
+
null
|
|
40
|
+
} else {
|
|
41
|
+
ensureRuntimeLocked().also { it.pendingSnapshots += 1 }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (runtime == null) {
|
|
45
|
+
promise.reject(ERROR_STATE_FAILED, "NetworkQuality has been invalidated")
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!runtime.handler.post {
|
|
50
|
+
try {
|
|
51
|
+
resolveCurrentState(runtime.monitor, promise)
|
|
52
|
+
} finally {
|
|
53
|
+
completeSnapshot(runtime)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
) {
|
|
57
|
+
discardUnavailableRuntime(runtime)
|
|
58
|
+
promise.reject(ERROR_STATE_FAILED, "The network snapshot thread is unavailable")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun startMonitoring(options: ReadableMap) {
|
|
63
|
+
if (invalidated) return
|
|
64
|
+
val throttleMs = options.readFiniteDouble("throttleMs", DEFAULT_THROTTLE_MS).toLong()
|
|
65
|
+
.coerceAtLeast(0L)
|
|
66
|
+
val threshold = options.readFiniteDouble(
|
|
67
|
+
"bandwidthChangeThresholdPct",
|
|
68
|
+
DEFAULT_THRESHOLD_PERCENT,
|
|
69
|
+
).coerceAtLeast(0.0)
|
|
70
|
+
|
|
71
|
+
val request = synchronized(lifecycleLock) {
|
|
72
|
+
if (invalidated) return
|
|
73
|
+
val runtime = ensureRuntimeLocked()
|
|
74
|
+
runtime.monitoring = true
|
|
75
|
+
runtime.commandVersion += 1
|
|
76
|
+
StartRequest(runtime, runtime.commandVersion)
|
|
77
|
+
}
|
|
78
|
+
if (!request.runtime.handler.post {
|
|
79
|
+
if (!isCurrentStart(request)) return@post
|
|
80
|
+
try {
|
|
81
|
+
if (!request.runtime.monitor.start(throttleMs, threshold)) {
|
|
82
|
+
completeFailedStart(request)
|
|
83
|
+
}
|
|
84
|
+
} catch (error: RuntimeException) {
|
|
85
|
+
Log.w(TAG, "Unable to start network monitoring", error)
|
|
86
|
+
try {
|
|
87
|
+
request.runtime.monitor.stop()
|
|
88
|
+
} catch (cleanupError: RuntimeException) {
|
|
89
|
+
Log.w(TAG, "Unable to roll back network monitoring", cleanupError)
|
|
90
|
+
}
|
|
91
|
+
completeFailedStart(request)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
) {
|
|
95
|
+
discardUnavailableRuntime(request.runtime)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override fun stopMonitoring() {
|
|
100
|
+
networkProbe.cancelActive()
|
|
101
|
+
val runtime = synchronized(lifecycleLock) {
|
|
102
|
+
val current = worker ?: return
|
|
103
|
+
if (!current.monitoring) return
|
|
104
|
+
current.monitoring = false
|
|
105
|
+
current.commandVersion += 1
|
|
106
|
+
current.stopPending = true
|
|
107
|
+
current
|
|
108
|
+
}
|
|
109
|
+
if (!runtime.handler.post {
|
|
110
|
+
try {
|
|
111
|
+
runtime.monitor.stop()
|
|
112
|
+
} catch (error: RuntimeException) {
|
|
113
|
+
Log.w(TAG, "Unable to stop network monitoring", error)
|
|
114
|
+
} finally {
|
|
115
|
+
completeStop(runtime)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
) {
|
|
119
|
+
discardUnavailableRuntime(runtime)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
override fun probe(options: ReadableMap, promise: Promise) {
|
|
124
|
+
if (invalidated) {
|
|
125
|
+
promise.reject(ERROR_PROBE_FAILED, "NetworkQuality has been invalidated")
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
networkProbe.probe(options, promise)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
override fun invalidate() {
|
|
132
|
+
val runtime = synchronized(lifecycleLock) {
|
|
133
|
+
if (invalidated) return
|
|
134
|
+
invalidated = true
|
|
135
|
+
worker.also { current ->
|
|
136
|
+
worker = null
|
|
137
|
+
current?.monitoring = false
|
|
138
|
+
current?.stopPending = true
|
|
139
|
+
current?.commandVersion = (current?.commandVersion ?: 0L) + 1
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
networkProbe.invalidate()
|
|
143
|
+
if (runtime != null) {
|
|
144
|
+
if (!runtime.handler.post {
|
|
145
|
+
try {
|
|
146
|
+
runtime.monitor.stop()
|
|
147
|
+
} catch (error: RuntimeException) {
|
|
148
|
+
Log.w(TAG, "Unable to invalidate network monitoring", error)
|
|
149
|
+
} finally {
|
|
150
|
+
runtime.thread.quitSafely()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
) {
|
|
154
|
+
runtime.thread.quitSafely()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
super.invalidate()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Called with [lifecycleLock] held. At most one serialized handler exists at a time. */
|
|
161
|
+
private fun ensureRuntimeLocked(): MonitorRuntime {
|
|
162
|
+
worker?.let { return it }
|
|
163
|
+
val generation = ++nextGeneration
|
|
164
|
+
val thread = HandlerThread(MONITOR_THREAD_NAME).apply { start() }
|
|
165
|
+
val handler = Handler(thread.looper)
|
|
166
|
+
val monitor = NetworkMonitor(
|
|
167
|
+
context = reactContext,
|
|
168
|
+
connectivityManager = connectivityManager,
|
|
169
|
+
handler = handler,
|
|
170
|
+
snapshotBuilder = snapshotBuilder,
|
|
171
|
+
onSnapshot = { snapshot -> emitSnapshot(generation, snapshot) },
|
|
172
|
+
)
|
|
173
|
+
return MonitorRuntime(generation, thread, handler, monitor).also { worker = it }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private fun completeSnapshot(runtime: MonitorRuntime) {
|
|
177
|
+
synchronized(lifecycleLock) {
|
|
178
|
+
if (runtime.pendingSnapshots > 0) runtime.pendingSnapshots -= 1
|
|
179
|
+
}
|
|
180
|
+
releaseIfIdle(runtime)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private fun completeStop(runtime: MonitorRuntime) {
|
|
184
|
+
synchronized(lifecycleLock) {
|
|
185
|
+
runtime.stopPending = false
|
|
186
|
+
}
|
|
187
|
+
releaseIfIdle(runtime)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private fun completeFailedStart(request: StartRequest) {
|
|
191
|
+
synchronized(lifecycleLock) {
|
|
192
|
+
if (
|
|
193
|
+
worker === request.runtime &&
|
|
194
|
+
request.runtime.commandVersion == request.commandVersion
|
|
195
|
+
) {
|
|
196
|
+
request.runtime.monitoring = false
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
releaseIfIdle(request.runtime)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private fun releaseIfIdle(runtime: MonitorRuntime) {
|
|
203
|
+
val shouldRelease = synchronized(lifecycleLock) {
|
|
204
|
+
if (
|
|
205
|
+
worker === runtime &&
|
|
206
|
+
!runtime.monitoring &&
|
|
207
|
+
!runtime.stopPending &&
|
|
208
|
+
runtime.pendingSnapshots == 0
|
|
209
|
+
) {
|
|
210
|
+
worker = null
|
|
211
|
+
true
|
|
212
|
+
} else {
|
|
213
|
+
false
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (shouldRelease) runtime.thread.quitSafely()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private fun discardUnavailableRuntime(runtime: MonitorRuntime) {
|
|
220
|
+
synchronized(lifecycleLock) {
|
|
221
|
+
if (worker === runtime) worker = null
|
|
222
|
+
runtime.monitoring = false
|
|
223
|
+
runtime.stopPending = false
|
|
224
|
+
runtime.pendingSnapshots = 0
|
|
225
|
+
}
|
|
226
|
+
runtime.thread.quitSafely()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private fun isCurrentStart(request: StartRequest): Boolean = synchronized(lifecycleLock) {
|
|
230
|
+
!invalidated &&
|
|
231
|
+
worker === request.runtime &&
|
|
232
|
+
request.runtime.monitoring &&
|
|
233
|
+
request.runtime.commandVersion == request.commandVersion
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private fun resolveCurrentState(monitor: NetworkMonitor, promise: Promise) {
|
|
237
|
+
try {
|
|
238
|
+
promise.resolve(monitor.readCurrentSnapshot().toWritableMap())
|
|
239
|
+
} catch (error: RuntimeException) {
|
|
240
|
+
promise.reject(ERROR_STATE_FAILED, "Unable to read the current network state", error)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun emitSnapshot(generation: Long, snapshot: NetworkSnapshot) {
|
|
245
|
+
val canEmit = synchronized(lifecycleLock) {
|
|
246
|
+
!invalidated && worker?.generation == generation && worker?.monitoring == true
|
|
247
|
+
}
|
|
248
|
+
if (!canEmit) return
|
|
249
|
+
try {
|
|
250
|
+
emitOnNetworkStateChange(snapshot.toWritableMap())
|
|
251
|
+
} catch (error: RuntimeException) {
|
|
252
|
+
Log.w(TAG, "Unable to emit a network state update", error)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private fun ReadableMap.readFiniteDouble(key: String, fallback: Double): Double = try {
|
|
257
|
+
getDouble(key).takeIf { it.isFinite() } ?: fallback
|
|
258
|
+
} catch (_: RuntimeException) {
|
|
259
|
+
fallback
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private class MonitorRuntime(
|
|
263
|
+
val generation: Long,
|
|
264
|
+
val thread: HandlerThread,
|
|
265
|
+
val handler: Handler,
|
|
266
|
+
val monitor: NetworkMonitor,
|
|
267
|
+
var monitoring: Boolean = false,
|
|
268
|
+
var stopPending: Boolean = false,
|
|
269
|
+
var pendingSnapshots: Int = 0,
|
|
270
|
+
var commandVersion: Long = 0,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
private data class StartRequest(
|
|
274
|
+
val runtime: MonitorRuntime,
|
|
275
|
+
val commandVersion: Long,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
companion object {
|
|
279
|
+
const val NAME = "NetworkQuality"
|
|
280
|
+
private const val TAG = "RNNetworkQuality"
|
|
281
|
+
private const val MONITOR_THREAD_NAME = "RNNetworkQuality"
|
|
282
|
+
private const val DEFAULT_THROTTLE_MS = 1_000.0
|
|
283
|
+
private const val DEFAULT_THRESHOLD_PERCENT = 10.0
|
|
284
|
+
private const val ERROR_STATE_FAILED = "E_PROBE_FAILED"
|
|
285
|
+
private const val ERROR_PROBE_FAILED = "E_PROBE_FAILED"
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.rnnetworkquality
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
class NetworkQualityPackage : BaseReactPackage() {
|
|
10
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
|
11
|
+
if (name == NetworkQualityModule.NAME) NetworkQualityModule(reactContext) else null
|
|
12
|
+
|
|
13
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
|
|
14
|
+
mapOf(
|
|
15
|
+
NetworkQualityModule.NAME to ReactModuleInfo(
|
|
16
|
+
name = NetworkQualityModule.NAME,
|
|
17
|
+
className = NetworkQualityModule::class.java.name,
|
|
18
|
+
canOverrideExistingModule = false,
|
|
19
|
+
needsEagerInit = false,
|
|
20
|
+
isCxxModule = false,
|
|
21
|
+
isTurboModule = true,
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
package com.rnnetworkquality
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.WritableMap
|
|
5
|
+
|
|
6
|
+
/** A complete point-in-time view of Android's default network. */
|
|
7
|
+
internal data class NetworkSnapshot(
|
|
8
|
+
val isConnected: Boolean,
|
|
9
|
+
val isValidated: Boolean?,
|
|
10
|
+
val isCaptivePortal: Boolean?,
|
|
11
|
+
val transport: String,
|
|
12
|
+
val isVpn: Boolean?,
|
|
13
|
+
val isExpensive: Boolean,
|
|
14
|
+
val isConstrained: Boolean,
|
|
15
|
+
val isRoaming: Boolean?,
|
|
16
|
+
val downlinkKbps: Int?,
|
|
17
|
+
val uplinkKbps: Int?,
|
|
18
|
+
val signalStrength: Int?,
|
|
19
|
+
val cellularGeneration: String?,
|
|
20
|
+
val supportsIPv4: Boolean?,
|
|
21
|
+
val supportsIPv6: Boolean?,
|
|
22
|
+
val supportsDNS: Boolean?,
|
|
23
|
+
val unsatisfiedReason: String?,
|
|
24
|
+
val timestamp: Long,
|
|
25
|
+
) {
|
|
26
|
+
fun equalsIgnoringTimestamp(other: NetworkSnapshot): Boolean =
|
|
27
|
+
copy(timestamp = other.timestamp) == other
|
|
28
|
+
|
|
29
|
+
fun toWritableMap(): WritableMap = Arguments.createMap().apply {
|
|
30
|
+
putBoolean("isConnected", isConnected)
|
|
31
|
+
putNullableBoolean("isValidated", isValidated)
|
|
32
|
+
putNullableBoolean("isCaptivePortal", isCaptivePortal)
|
|
33
|
+
putString("transport", transport)
|
|
34
|
+
putNullableBoolean("isVpn", isVpn)
|
|
35
|
+
putBoolean("isExpensive", isExpensive)
|
|
36
|
+
putBoolean("isConstrained", isConstrained)
|
|
37
|
+
putNullableBoolean("isRoaming", isRoaming)
|
|
38
|
+
putNullableNumber("downlinkKbps", downlinkKbps)
|
|
39
|
+
putNullableNumber("uplinkKbps", uplinkKbps)
|
|
40
|
+
putNullableNumber("signalStrength", signalStrength)
|
|
41
|
+
putNullableString("cellularGeneration", cellularGeneration)
|
|
42
|
+
putNullableBoolean("supportsIPv4", supportsIPv4)
|
|
43
|
+
putNullableBoolean("supportsIPv6", supportsIPv6)
|
|
44
|
+
putNullableBoolean("supportsDNS", supportsDNS)
|
|
45
|
+
putNullableString("unsatisfiedReason", unsatisfiedReason)
|
|
46
|
+
putDouble("timestamp", timestamp.toDouble())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun WritableMap.putNullableBoolean(key: String, value: Boolean?) {
|
|
50
|
+
if (value == null) putNull(key) else putBoolean(key, value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private fun WritableMap.putNullableNumber(key: String, value: Int?) {
|
|
54
|
+
if (value == null) putNull(key) else putDouble(key, value.toDouble())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private fun WritableMap.putNullableString(key: String, value: String?) {
|
|
58
|
+
if (value == null) putNull(key) else putString(key, value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
companion object {
|
|
62
|
+
fun disconnected(timestamp: Long, isConstrained: Boolean): NetworkSnapshot =
|
|
63
|
+
NetworkSnapshot(
|
|
64
|
+
isConnected = false,
|
|
65
|
+
isValidated = false,
|
|
66
|
+
isCaptivePortal = false,
|
|
67
|
+
transport = "none",
|
|
68
|
+
isVpn = false,
|
|
69
|
+
isExpensive = false,
|
|
70
|
+
isConstrained = isConstrained,
|
|
71
|
+
isRoaming = null,
|
|
72
|
+
downlinkKbps = null,
|
|
73
|
+
uplinkKbps = null,
|
|
74
|
+
signalStrength = null,
|
|
75
|
+
cellularGeneration = null,
|
|
76
|
+
supportsIPv4 = null,
|
|
77
|
+
supportsIPv6 = null,
|
|
78
|
+
supportsDNS = null,
|
|
79
|
+
unsatisfiedReason = null,
|
|
80
|
+
timestamp = timestamp,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
package com.rnnetworkquality
|
|
2
|
+
|
|
3
|
+
import android.net.ConnectivityManager
|
|
4
|
+
import android.net.LinkProperties
|
|
5
|
+
import android.net.Network
|
|
6
|
+
import android.net.NetworkCapabilities
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import java.net.Inet4Address
|
|
9
|
+
import java.net.Inet6Address
|
|
10
|
+
|
|
11
|
+
/** Converts Android connectivity primitives into the cross-platform snapshot contract. */
|
|
12
|
+
internal class SnapshotBuilder(
|
|
13
|
+
private val connectivityManager: ConnectivityManager,
|
|
14
|
+
private val cellularInfo: CellularInfo,
|
|
15
|
+
) {
|
|
16
|
+
fun build(
|
|
17
|
+
network: Network?,
|
|
18
|
+
capabilities: NetworkCapabilities?,
|
|
19
|
+
linkProperties: LinkProperties?,
|
|
20
|
+
timestamp: Long = System.currentTimeMillis(),
|
|
21
|
+
): NetworkSnapshot {
|
|
22
|
+
if (network == null || capabilities == null) {
|
|
23
|
+
return NetworkSnapshot.disconnected(timestamp, isConstrained())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
val hasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
27
|
+
val hasCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
|
28
|
+
val hasEthernet = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
|
29
|
+
val hasUsb =
|
|
30
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
|
31
|
+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_USB)
|
|
32
|
+
val hasBluetooth = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)
|
|
33
|
+
val hasVpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
|
34
|
+
val transportMapping = Mappers.mapTransport(
|
|
35
|
+
hasNetwork = true,
|
|
36
|
+
hasWifi = hasWifi,
|
|
37
|
+
hasCellular = hasCellular,
|
|
38
|
+
hasEthernet = hasEthernet,
|
|
39
|
+
hasUsb = hasUsb,
|
|
40
|
+
hasBluetooth = hasBluetooth,
|
|
41
|
+
hasVpn = hasVpn,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
val temporarilyNotMetered =
|
|
45
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
|
46
|
+
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED)
|
|
47
|
+
val isExpensive =
|
|
48
|
+
!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) &&
|
|
49
|
+
!temporarilyNotMetered
|
|
50
|
+
|
|
51
|
+
val downlink = capabilities.linkDownstreamBandwidthKbps.takeIf { it > 0 }
|
|
52
|
+
val uplink = capabilities.linkUpstreamBandwidthKbps.takeIf { it > 0 }
|
|
53
|
+
val signal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
54
|
+
capabilities.signalStrength.takeUnless {
|
|
55
|
+
it == NetworkCapabilities.SIGNAL_STRENGTH_UNSPECIFIED
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return NetworkSnapshot(
|
|
62
|
+
isConnected = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
|
|
63
|
+
isValidated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
|
|
64
|
+
isCaptivePortal = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL),
|
|
65
|
+
transport = transportMapping.transport,
|
|
66
|
+
isVpn = transportMapping.isVpn,
|
|
67
|
+
isExpensive = isExpensive,
|
|
68
|
+
isConstrained = isConstrained(),
|
|
69
|
+
isRoaming = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
70
|
+
!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
|
|
71
|
+
} else {
|
|
72
|
+
null
|
|
73
|
+
},
|
|
74
|
+
downlinkKbps = downlink,
|
|
75
|
+
uplinkKbps = uplink,
|
|
76
|
+
signalStrength = signal,
|
|
77
|
+
cellularGeneration = cellularInfo.generation(transportMapping.transport == "cellular"),
|
|
78
|
+
supportsIPv4 = linkProperties?.linkAddresses?.any { it.address is Inet4Address },
|
|
79
|
+
supportsIPv6 = linkProperties?.linkAddresses?.any { it.address is Inet6Address },
|
|
80
|
+
supportsDNS = linkProperties?.dnsServers?.isNotEmpty(),
|
|
81
|
+
unsatisfiedReason = null,
|
|
82
|
+
timestamp = timestamp,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun isConstrained(): Boolean = try {
|
|
87
|
+
connectivityManager.restrictBackgroundStatus ==
|
|
88
|
+
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
|
|
89
|
+
} catch (_: RuntimeException) {
|
|
90
|
+
false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package com.rnnetworkquality
|
|
2
|
+
|
|
3
|
+
internal fun interface Cancellable {
|
|
4
|
+
fun cancel()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
internal fun interface Clock {
|
|
8
|
+
fun nowMs(): Long
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
internal fun interface Scheduler {
|
|
12
|
+
fun schedule(delayMs: Long, task: () -> Unit): Cancellable
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* De-duplicates snapshots and applies leading/trailing throttling to minor signal changes.
|
|
17
|
+
* All calls must be serialized by the owner; the class itself has no platform dependencies.
|
|
18
|
+
*/
|
|
19
|
+
internal class Throttler(
|
|
20
|
+
private val clock: Clock,
|
|
21
|
+
private val scheduler: Scheduler,
|
|
22
|
+
private val emit: (NetworkSnapshot) -> Unit,
|
|
23
|
+
throttleMs: Long,
|
|
24
|
+
bandwidthChangeThresholdPct: Double,
|
|
25
|
+
) {
|
|
26
|
+
private var throttleMs = throttleMs.coerceAtLeast(0L)
|
|
27
|
+
private var bandwidthChangeThresholdPct = bandwidthChangeThresholdPct.coerceAtLeast(0.0)
|
|
28
|
+
private var lastEmitted: NetworkSnapshot? = null
|
|
29
|
+
private var lastEmitTime = 0L
|
|
30
|
+
private var pending: NetworkSnapshot? = null
|
|
31
|
+
private var timer: Cancellable? = null
|
|
32
|
+
|
|
33
|
+
fun updateOptions(throttleMs: Long, bandwidthChangeThresholdPct: Double) {
|
|
34
|
+
this.throttleMs = throttleMs.coerceAtLeast(0L)
|
|
35
|
+
this.bandwidthChangeThresholdPct = bandwidthChangeThresholdPct.coerceAtLeast(0.0)
|
|
36
|
+
|
|
37
|
+
val latestPending = pending ?: return
|
|
38
|
+
cancelPending()
|
|
39
|
+
submit(latestPending)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fun submit(snapshot: NetworkSnapshot) {
|
|
43
|
+
val previous = lastEmitted
|
|
44
|
+
if (previous == null) {
|
|
45
|
+
emitNow(snapshot)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (snapshot.equalsIgnoringTimestamp(previous)) {
|
|
50
|
+
cancelPending()
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Mappers.isSignificantChange(previous, snapshot)) {
|
|
55
|
+
cancelPending()
|
|
56
|
+
emitNow(snapshot)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!Mappers.exceedsAnyMinorThreshold(previous, snapshot, bandwidthChangeThresholdPct)) {
|
|
61
|
+
cancelPending()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
val elapsed = (clock.nowMs() - lastEmitTime).coerceAtLeast(0L)
|
|
66
|
+
if (elapsed >= throttleMs) {
|
|
67
|
+
cancelPending()
|
|
68
|
+
emitNow(snapshot)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pending = snapshot
|
|
73
|
+
if (timer == null) {
|
|
74
|
+
timer = scheduler.schedule(throttleMs - elapsed) {
|
|
75
|
+
timer = null
|
|
76
|
+
val trailing = pending
|
|
77
|
+
pending = null
|
|
78
|
+
if (trailing != null) emitNow(trailing)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fun reset() {
|
|
84
|
+
cancelPending()
|
|
85
|
+
lastEmitted = null
|
|
86
|
+
lastEmitTime = 0L
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private fun emitNow(snapshot: NetworkSnapshot) {
|
|
90
|
+
lastEmitted = snapshot
|
|
91
|
+
lastEmitTime = clock.nowMs()
|
|
92
|
+
emit(snapshot)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private fun cancelPending() {
|
|
96
|
+
timer?.cancel()
|
|
97
|
+
timer = null
|
|
98
|
+
pending = null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import CoreTelephony
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
final class CellularInfo {
|
|
5
|
+
private let networkInfo = CTTelephonyNetworkInfo()
|
|
6
|
+
|
|
7
|
+
func generation(isCellular: Bool) -> String? {
|
|
8
|
+
guard isCellular else { return nil }
|
|
9
|
+
|
|
10
|
+
let technology: String?
|
|
11
|
+
if #available(iOS 12.0, *) {
|
|
12
|
+
guard let serviceIdentifier = networkInfo.dataServiceIdentifier else {
|
|
13
|
+
return nil
|
|
14
|
+
}
|
|
15
|
+
technology = networkInfo.serviceCurrentRadioAccessTechnology?[serviceIdentifier]
|
|
16
|
+
} else {
|
|
17
|
+
technology = networkInfo.currentRadioAccessTechnology
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
guard let technology else { return nil }
|
|
21
|
+
return Self.map(technology)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private static func map(_ technology: String) -> String? {
|
|
25
|
+
switch technology {
|
|
26
|
+
case CTRadioAccessTechnologyGPRS,
|
|
27
|
+
CTRadioAccessTechnologyEdge,
|
|
28
|
+
CTRadioAccessTechnologyCDMA1x:
|
|
29
|
+
return "2g"
|
|
30
|
+
case CTRadioAccessTechnologyWCDMA,
|
|
31
|
+
CTRadioAccessTechnologyHSDPA,
|
|
32
|
+
CTRadioAccessTechnologyHSUPA,
|
|
33
|
+
CTRadioAccessTechnologyCDMAEVDORev0,
|
|
34
|
+
CTRadioAccessTechnologyCDMAEVDORevA,
|
|
35
|
+
CTRadioAccessTechnologyCDMAEVDORevB,
|
|
36
|
+
CTRadioAccessTechnologyeHRPD:
|
|
37
|
+
return "3g"
|
|
38
|
+
case CTRadioAccessTechnologyLTE:
|
|
39
|
+
return "4g"
|
|
40
|
+
case CTRadioAccessTechnologyNR,
|
|
41
|
+
CTRadioAccessTechnologyNRNSA:
|
|
42
|
+
return "5g"
|
|
43
|
+
default:
|
|
44
|
+
return nil
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|