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,668 @@
|
|
|
1
|
+
package com.rnnetworkquality
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.Network
|
|
6
|
+
import android.net.NetworkCapabilities
|
|
7
|
+
import android.os.SystemClock
|
|
8
|
+
import com.facebook.react.bridge.Arguments
|
|
9
|
+
import com.facebook.react.bridge.Promise
|
|
10
|
+
import com.facebook.react.bridge.ReadableMap
|
|
11
|
+
import java.io.IOException
|
|
12
|
+
import java.net.CookieHandler
|
|
13
|
+
import java.net.HttpURLConnection
|
|
14
|
+
import java.net.SocketTimeoutException
|
|
15
|
+
import java.net.URI
|
|
16
|
+
import java.net.URL
|
|
17
|
+
import java.util.UUID
|
|
18
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
19
|
+
import java.util.concurrent.ExecutorService
|
|
20
|
+
import java.util.concurrent.Executors
|
|
21
|
+
import java.util.concurrent.RejectedExecutionException
|
|
22
|
+
import java.util.concurrent.ScheduledExecutorService
|
|
23
|
+
import java.util.concurrent.ScheduledFuture
|
|
24
|
+
import java.util.concurrent.TimeUnit
|
|
25
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
26
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
27
|
+
import kotlin.math.ceil
|
|
28
|
+
|
|
29
|
+
/** Runs each explicit probe on its own executor bound to the current Android network. */
|
|
30
|
+
internal class NetworkProbe(context: Context) {
|
|
31
|
+
private val connectivityManager =
|
|
32
|
+
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
33
|
+
private val operations = ConcurrentHashMap.newKeySet<ProbeOperation>()
|
|
34
|
+
|
|
35
|
+
@Volatile
|
|
36
|
+
private var invalidated = false
|
|
37
|
+
|
|
38
|
+
fun probe(options: ReadableMap, promise: Promise) {
|
|
39
|
+
val parsed = try {
|
|
40
|
+
parseOptions(options)
|
|
41
|
+
} catch (error: InvalidUrlException) {
|
|
42
|
+
promise.reject(ERROR_INVALID_URL, error.message, error)
|
|
43
|
+
return
|
|
44
|
+
} catch (error: IllegalArgumentException) {
|
|
45
|
+
promise.reject(ERROR_PROBE_FAILED, error.message, error)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (invalidated) {
|
|
50
|
+
promise.reject(ERROR_PROBE_FAILED, "The network probe is unavailable")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
val executor = Executors.newSingleThreadExecutor { runnable ->
|
|
55
|
+
Thread(runnable, "RNNetworkQualityProbe").apply { isDaemon = true }
|
|
56
|
+
}
|
|
57
|
+
val timeoutExecutor = Executors.newSingleThreadScheduledExecutor { runnable ->
|
|
58
|
+
Thread(runnable, "RNNetworkQualityProbeTimeout").apply { isDaemon = true }
|
|
59
|
+
}
|
|
60
|
+
val operation = ProbeOperation(executor, timeoutExecutor, promise)
|
|
61
|
+
synchronized(operations) {
|
|
62
|
+
operations += operation
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
executor.execute {
|
|
66
|
+
try {
|
|
67
|
+
runProbe(parsed, operation)
|
|
68
|
+
} finally {
|
|
69
|
+
operation.finish()
|
|
70
|
+
synchronized(operations) {
|
|
71
|
+
operations -= operation
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (error: RejectedExecutionException) {
|
|
76
|
+
synchronized(operations) {
|
|
77
|
+
operations -= operation
|
|
78
|
+
}
|
|
79
|
+
operation.cancel("The network probe executor is unavailable", error)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fun invalidate() {
|
|
84
|
+
invalidated = true
|
|
85
|
+
cancelActive()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fun cancelActive() {
|
|
89
|
+
val active = synchronized(operations) {
|
|
90
|
+
operations.toList().also { operations.clear() }
|
|
91
|
+
}
|
|
92
|
+
active.forEach { it.cancel("The network probe was cancelled") }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private fun runProbe(options: ProbeOptions, operation: ProbeOperation) {
|
|
96
|
+
if (operation.isCancelled || invalidated) {
|
|
97
|
+
operation.reject(ERROR_PROBE_FAILED, "The network probe was cancelled")
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
val startedNanos = SystemClock.elapsedRealtimeNanos()
|
|
102
|
+
val deadlineNanos = startedNanos + options.timeoutMs * NANOS_PER_MILLISECOND
|
|
103
|
+
try {
|
|
104
|
+
operation.armTimeout(options.timeoutMs)
|
|
105
|
+
} catch (error: RejectedExecutionException) {
|
|
106
|
+
operation.reject(ERROR_PROBE_FAILED, "The network probe was cancelled", error)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
val network = activeConnectedNetwork()
|
|
111
|
+
if (operation.hasTimedOut) {
|
|
112
|
+
operation.reject(
|
|
113
|
+
ERROR_PROBE_TIMEOUT,
|
|
114
|
+
"The network probe exceeded its ${options.timeoutMs} ms timeout",
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (network == null) {
|
|
119
|
+
operation.reject(ERROR_OFFLINE, "A connected default network is required to run a probe")
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
ensureCookieIsolation()
|
|
125
|
+
} catch (error: CookieIsolationException) {
|
|
126
|
+
operation.reject(ERROR_PROBE_FAILED, error.message, error)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (operation.hasTimedOut) {
|
|
130
|
+
operation.reject(
|
|
131
|
+
ERROR_PROBE_TIMEOUT,
|
|
132
|
+
"The network probe exceeded its ${options.timeoutMs} ms timeout",
|
|
133
|
+
)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
val rttMs = try {
|
|
138
|
+
measureLatency(network, options, deadlineNanos, operation)
|
|
139
|
+
} catch (error: Exception) {
|
|
140
|
+
val timedOut = isTimeout(error, deadlineNanos, operation)
|
|
141
|
+
val code = if (timedOut) ERROR_PROBE_TIMEOUT else ERROR_PROBE_FAILED
|
|
142
|
+
val message = if (timedOut) {
|
|
143
|
+
"The network probe exceeded its ${options.timeoutMs} ms timeout"
|
|
144
|
+
} else {
|
|
145
|
+
"The latency probe failed: ${messageFor(error)}"
|
|
146
|
+
}
|
|
147
|
+
operation.reject(code, message, error)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
var bytesReceived = 0L
|
|
152
|
+
var downlinkKbps: Double? = null
|
|
153
|
+
var downloadError: String? = null
|
|
154
|
+
if (options.downloadUrl != null) {
|
|
155
|
+
try {
|
|
156
|
+
val download = measureThroughput(
|
|
157
|
+
network,
|
|
158
|
+
options,
|
|
159
|
+
deadlineNanos,
|
|
160
|
+
operation,
|
|
161
|
+
) { received ->
|
|
162
|
+
bytesReceived = received
|
|
163
|
+
}
|
|
164
|
+
bytesReceived = download.bytesReceived
|
|
165
|
+
downlinkKbps = download.downlinkKbps
|
|
166
|
+
downloadError = download.downloadError
|
|
167
|
+
} catch (error: Exception) {
|
|
168
|
+
downloadError = if (isTimeout(error, deadlineNanos, operation)) {
|
|
169
|
+
"Download phase timed out"
|
|
170
|
+
} else {
|
|
171
|
+
"Download phase failed: ${messageFor(error)}"
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (operation.hasTimedOut || SystemClock.elapsedRealtimeNanos() >= deadlineNanos) {
|
|
177
|
+
if (options.downloadUrl == null) {
|
|
178
|
+
operation.reject(
|
|
179
|
+
ERROR_PROBE_TIMEOUT,
|
|
180
|
+
"The network probe exceeded its ${options.timeoutMs} ms timeout",
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
downlinkKbps = null
|
|
185
|
+
downloadError = "Download phase timed out"
|
|
186
|
+
}
|
|
187
|
+
if (operation.isCancelled || invalidated) {
|
|
188
|
+
operation.reject(ERROR_PROBE_FAILED, "The network probe was cancelled")
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
val durationMs = nanosToMilliseconds(SystemClock.elapsedRealtimeNanos() - startedNanos)
|
|
193
|
+
operation.resolve(
|
|
194
|
+
Arguments.createMap().apply {
|
|
195
|
+
putDouble("rttMs", rttMs)
|
|
196
|
+
if (downlinkKbps == null) putNull("downlinkKbps") else {
|
|
197
|
+
putDouble("downlinkKbps", downlinkKbps)
|
|
198
|
+
}
|
|
199
|
+
putDouble("bytesReceived", bytesReceived.toDouble())
|
|
200
|
+
putDouble("durationMs", durationMs)
|
|
201
|
+
if (downloadError == null) putNull("downloadError") else {
|
|
202
|
+
putString("downloadError", downloadError)
|
|
203
|
+
}
|
|
204
|
+
putDouble("timestamp", System.currentTimeMillis().toDouble())
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private fun activeConnectedNetwork(): Network? = try {
|
|
210
|
+
val network = connectivityManager.activeNetwork ?: return null
|
|
211
|
+
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return null
|
|
212
|
+
network.takeIf {
|
|
213
|
+
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
214
|
+
}
|
|
215
|
+
} catch (_: RuntimeException) {
|
|
216
|
+
null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private fun measureLatency(
|
|
220
|
+
network: Network,
|
|
221
|
+
options: ProbeOptions,
|
|
222
|
+
deadlineNanos: Long,
|
|
223
|
+
operation: ProbeOperation,
|
|
224
|
+
): Double {
|
|
225
|
+
val samples = mutableListOf<Double>()
|
|
226
|
+
for (index in 0..options.latencySamples) {
|
|
227
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
228
|
+
val connection = openConnection(network, options.latencyUrl, deadlineNanos, operation)
|
|
229
|
+
var completedSuccessfully = false
|
|
230
|
+
try {
|
|
231
|
+
val timing = receiveResponseHeaders(connection, deadlineNanos, operation)
|
|
232
|
+
if (index > 0) {
|
|
233
|
+
samples += nanosToMilliseconds(timing.receivedNanos - timing.startedNanos)
|
|
234
|
+
}
|
|
235
|
+
readResponseFully(connection, deadlineNanos, operation)
|
|
236
|
+
completedSuccessfully = true
|
|
237
|
+
} finally {
|
|
238
|
+
operation.clearConnection(connection)
|
|
239
|
+
// Fully consuming and closing a successful response returns its socket to
|
|
240
|
+
// HttpURLConnection's pool, allowing retained samples to reuse DNS/TCP/TLS.
|
|
241
|
+
// Failed or cancelled requests cannot be safely pooled.
|
|
242
|
+
if (!completedSuccessfully) connection.disconnect()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
samples.sort()
|
|
247
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
248
|
+
val middle = samples.size / 2
|
|
249
|
+
return if (samples.size % 2 == 1) {
|
|
250
|
+
samples[middle]
|
|
251
|
+
} else {
|
|
252
|
+
(samples[middle - 1] + samples[middle]) / 2.0
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private fun measureThroughput(
|
|
257
|
+
network: Network,
|
|
258
|
+
options: ProbeOptions,
|
|
259
|
+
deadlineNanos: Long,
|
|
260
|
+
operation: ProbeOperation,
|
|
261
|
+
onBytesReceived: (Long) -> Unit,
|
|
262
|
+
): DownloadMeasurement {
|
|
263
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
264
|
+
val downloadUrl = requireNotNull(options.downloadUrl)
|
|
265
|
+
val connection = openConnection(network, downloadUrl, deadlineNanos, operation)
|
|
266
|
+
try {
|
|
267
|
+
connection.readTimeout = remainingTimeoutMs(deadlineNanos)
|
|
268
|
+
receiveResponseHeaders(connection, deadlineNanos, operation)
|
|
269
|
+
val input = responseStream(connection)
|
|
270
|
+
var total = 0L
|
|
271
|
+
var firstByteAtNanos: Long? = null
|
|
272
|
+
var finishedAtNanos = SystemClock.elapsedRealtimeNanos()
|
|
273
|
+
if (input != null) {
|
|
274
|
+
input.use { stream ->
|
|
275
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
276
|
+
while (true) {
|
|
277
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
278
|
+
val nowNanos = SystemClock.elapsedRealtimeNanos()
|
|
279
|
+
val bodyStarted = firstByteAtNanos
|
|
280
|
+
if (
|
|
281
|
+
bodyStarted != null &&
|
|
282
|
+
DownloadPolicy.shouldStop(
|
|
283
|
+
total,
|
|
284
|
+
nanosToMilliseconds(nowNanos - bodyStarted),
|
|
285
|
+
options.downloadMaxBytes,
|
|
286
|
+
options.downloadMaxDurationMs,
|
|
287
|
+
)
|
|
288
|
+
) {
|
|
289
|
+
finishedAtNanos = nowNanos
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
connection.readTimeout = if (bodyStarted == null) {
|
|
294
|
+
remainingTimeoutMs(deadlineNanos)
|
|
295
|
+
} else {
|
|
296
|
+
minOf(
|
|
297
|
+
remainingTimeoutMs(deadlineNanos),
|
|
298
|
+
remainingDownloadTimeoutMs(bodyStarted, options.downloadMaxDurationMs),
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
val allowed = minOf(
|
|
302
|
+
buffer.size.toLong(),
|
|
303
|
+
options.downloadMaxBytes - total,
|
|
304
|
+
).toInt()
|
|
305
|
+
if (allowed <= 0) {
|
|
306
|
+
finishedAtNanos = nowNanos
|
|
307
|
+
break
|
|
308
|
+
}
|
|
309
|
+
val count = try {
|
|
310
|
+
stream.read(buffer, 0, allowed)
|
|
311
|
+
} catch (error: SocketTimeoutException) {
|
|
312
|
+
val started = firstByteAtNanos
|
|
313
|
+
if (
|
|
314
|
+
started != null &&
|
|
315
|
+
nanosToMilliseconds(SystemClock.elapsedRealtimeNanos() - started) >=
|
|
316
|
+
options.downloadMaxDurationMs
|
|
317
|
+
) {
|
|
318
|
+
finishedAtNanos = SystemClock.elapsedRealtimeNanos()
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
throw error
|
|
322
|
+
}
|
|
323
|
+
if (count < 0) break
|
|
324
|
+
val receivedAtNanos = SystemClock.elapsedRealtimeNanos()
|
|
325
|
+
if (firstByteAtNanos == null) firstByteAtNanos = receivedAtNanos
|
|
326
|
+
total += count
|
|
327
|
+
finishedAtNanos = receivedAtNanos
|
|
328
|
+
onBytesReceived(total)
|
|
329
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
val bodyStarted = firstByteAtNanos
|
|
334
|
+
val elapsedMs = if (bodyStarted == null) {
|
|
335
|
+
0.0
|
|
336
|
+
} else {
|
|
337
|
+
nanosToMilliseconds(finishedAtNanos - bodyStarted)
|
|
338
|
+
}
|
|
339
|
+
val downloadError = DownloadPolicy.measurementError(total, elapsedMs)
|
|
340
|
+
val measuredKbps = DownloadPolicy.throughputKbps(total, elapsedMs)
|
|
341
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
342
|
+
return DownloadMeasurement(total, measuredKbps, downloadError)
|
|
343
|
+
} finally {
|
|
344
|
+
operation.clearConnection(connection)
|
|
345
|
+
// Throughput is the final request, so explicitly close its socket after the body/cap.
|
|
346
|
+
connection.disconnect()
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private fun openConnection(
|
|
351
|
+
network: Network,
|
|
352
|
+
url: URL,
|
|
353
|
+
deadlineNanos: Long,
|
|
354
|
+
operation: ProbeOperation,
|
|
355
|
+
): HttpURLConnection {
|
|
356
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
357
|
+
ensureCookieIsolation()
|
|
358
|
+
val connection = network.openConnection(withCacheBuster(url)) as? HttpURLConnection
|
|
359
|
+
?: throw IOException("Probe URL did not create an HTTP connection")
|
|
360
|
+
operation.setConnection(connection)
|
|
361
|
+
connection.apply {
|
|
362
|
+
useCaches = false
|
|
363
|
+
defaultUseCaches = false
|
|
364
|
+
instanceFollowRedirects = true
|
|
365
|
+
requestMethod = "GET"
|
|
366
|
+
setRequestProperty("Cache-Control", "no-cache")
|
|
367
|
+
setRequestProperty("Pragma", "no-cache")
|
|
368
|
+
setRequestProperty("Cookie", "")
|
|
369
|
+
}
|
|
370
|
+
return connection
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private fun receiveResponseHeaders(
|
|
374
|
+
connection: HttpURLConnection,
|
|
375
|
+
deadlineNanos: Long,
|
|
376
|
+
operation: ProbeOperation,
|
|
377
|
+
): HeaderTiming {
|
|
378
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
379
|
+
ensureCookieIsolation()
|
|
380
|
+
connection.connectTimeout = remainingTimeoutMs(deadlineNanos)
|
|
381
|
+
connection.readTimeout = remainingTimeoutMs(deadlineNanos)
|
|
382
|
+
|
|
383
|
+
val startedNanos = SystemClock.elapsedRealtimeNanos()
|
|
384
|
+
connection.connect()
|
|
385
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
386
|
+
ensureCookieIsolation()
|
|
387
|
+
|
|
388
|
+
// connect and waiting for response headers share one deadline, rather than each
|
|
389
|
+
// receiving the original timeout as an independent budget.
|
|
390
|
+
connection.readTimeout = remainingTimeoutMs(deadlineNanos)
|
|
391
|
+
connection.responseCode
|
|
392
|
+
val receivedNanos = SystemClock.elapsedRealtimeNanos()
|
|
393
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
394
|
+
ensureCookieIsolation()
|
|
395
|
+
return HeaderTiming(startedNanos, receivedNanos)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private fun readResponseFully(
|
|
399
|
+
connection: HttpURLConnection,
|
|
400
|
+
deadlineNanos: Long,
|
|
401
|
+
operation: ProbeOperation,
|
|
402
|
+
) {
|
|
403
|
+
connection.readTimeout = remainingTimeoutMs(deadlineNanos)
|
|
404
|
+
responseStream(connection)?.use { stream ->
|
|
405
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
406
|
+
while (true) {
|
|
407
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
408
|
+
connection.readTimeout = remainingTimeoutMs(deadlineNanos)
|
|
409
|
+
val count = stream.read(buffer)
|
|
410
|
+
ensureWithinDeadline(deadlineNanos, operation)
|
|
411
|
+
if (count < 0) break
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* HttpURLConnection only exposes a process-wide CookieHandler. Mutating it, even briefly,
|
|
418
|
+
* could race unrelated host-app requests, so probes fail closed when one is installed.
|
|
419
|
+
*/
|
|
420
|
+
private fun ensureCookieIsolation() {
|
|
421
|
+
val processHandler = try {
|
|
422
|
+
CookieHandler.getDefault()
|
|
423
|
+
} catch (error: SecurityException) {
|
|
424
|
+
throw CookieIsolationException(
|
|
425
|
+
"Cannot verify process-wide cookie isolation for the network probe",
|
|
426
|
+
error,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
if (processHandler != null) {
|
|
430
|
+
throw CookieIsolationException(
|
|
431
|
+
"Network probe refused to use the host app's process-wide CookieHandler"
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private fun responseStream(connection: HttpURLConnection) = try {
|
|
437
|
+
connection.inputStream
|
|
438
|
+
} catch (error: IOException) {
|
|
439
|
+
connection.errorStream ?: throw error
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private fun remainingTimeoutMs(deadlineNanos: Long): Int {
|
|
443
|
+
val remainingNanos = deadlineNanos - SystemClock.elapsedRealtimeNanos()
|
|
444
|
+
if (remainingNanos <= 0L) throw ProbeTimeoutException()
|
|
445
|
+
return ceil(remainingNanos.toDouble() / NANOS_PER_MILLISECOND)
|
|
446
|
+
.coerceAtMost(Int.MAX_VALUE.toDouble())
|
|
447
|
+
.toInt()
|
|
448
|
+
.coerceAtLeast(1)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private fun remainingDownloadTimeoutMs(
|
|
452
|
+
firstByteAtNanos: Long,
|
|
453
|
+
maximumDurationMs: Long,
|
|
454
|
+
): Int {
|
|
455
|
+
val elapsedNanos = SystemClock.elapsedRealtimeNanos() - firstByteAtNanos
|
|
456
|
+
val remainingNanos = maximumDurationMs * NANOS_PER_MILLISECOND - elapsedNanos
|
|
457
|
+
if (remainingNanos <= 0L) return 1
|
|
458
|
+
return ceil(remainingNanos.toDouble() / NANOS_PER_MILLISECOND)
|
|
459
|
+
.coerceAtMost(Int.MAX_VALUE.toDouble())
|
|
460
|
+
.toInt()
|
|
461
|
+
.coerceAtLeast(1)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private fun ensureWithinDeadline(deadlineNanos: Long, operation: ProbeOperation) {
|
|
465
|
+
if (operation.hasTimedOut || SystemClock.elapsedRealtimeNanos() >= deadlineNanos) {
|
|
466
|
+
throw ProbeTimeoutException()
|
|
467
|
+
}
|
|
468
|
+
if (invalidated || operation.isCancelled || Thread.currentThread().isInterrupted) {
|
|
469
|
+
throw IOException("Probe cancelled")
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private fun isTimeout(
|
|
474
|
+
error: Exception,
|
|
475
|
+
deadlineNanos: Long,
|
|
476
|
+
operation: ProbeOperation,
|
|
477
|
+
): Boolean =
|
|
478
|
+
error is ProbeTimeoutException ||
|
|
479
|
+
error is SocketTimeoutException ||
|
|
480
|
+
operation.hasTimedOut ||
|
|
481
|
+
SystemClock.elapsedRealtimeNanos() >= deadlineNanos
|
|
482
|
+
|
|
483
|
+
private fun parseOptions(options: ReadableMap): ProbeOptions {
|
|
484
|
+
val latencyUrl = parseHttpUrl(options.getString("latencyUrl"), "latencyUrl")
|
|
485
|
+
val downloadUrl = if (options.isNull("downloadUrl")) {
|
|
486
|
+
null
|
|
487
|
+
} else {
|
|
488
|
+
parseHttpUrl(options.getString("downloadUrl"), "downloadUrl")
|
|
489
|
+
}
|
|
490
|
+
val latencySamplesValue = options.getDouble("latencySamples")
|
|
491
|
+
val timeoutValue = options.getDouble("timeoutMs")
|
|
492
|
+
val downloadMaxDurationValue = options.getDouble("downloadMaxDurationMs")
|
|
493
|
+
val downloadMaxBytesValue = options.getDouble("downloadMaxBytes")
|
|
494
|
+
require(latencySamplesValue.isFinite() && latencySamplesValue % 1.0 == 0.0) {
|
|
495
|
+
"latencySamples must be an integer"
|
|
496
|
+
}
|
|
497
|
+
val latencySamples = latencySamplesValue.toInt()
|
|
498
|
+
require(latencySamples in 1..MAX_LATENCY_SAMPLES) {
|
|
499
|
+
"latencySamples must be between 1 and $MAX_LATENCY_SAMPLES"
|
|
500
|
+
}
|
|
501
|
+
require(timeoutValue.isFinite() && timeoutValue > 0 && timeoutValue <= Int.MAX_VALUE) {
|
|
502
|
+
"timeoutMs must be greater than 0 and at most ${Int.MAX_VALUE}"
|
|
503
|
+
}
|
|
504
|
+
require(
|
|
505
|
+
downloadMaxDurationValue.isFinite() &&
|
|
506
|
+
downloadMaxDurationValue > 0 &&
|
|
507
|
+
downloadMaxDurationValue <= Int.MAX_VALUE
|
|
508
|
+
) {
|
|
509
|
+
"downloadMaxDurationMs must be greater than 0 and at most ${Int.MAX_VALUE}"
|
|
510
|
+
}
|
|
511
|
+
require(
|
|
512
|
+
downloadMaxBytesValue.isFinite() &&
|
|
513
|
+
downloadMaxBytesValue % 1.0 == 0.0 &&
|
|
514
|
+
downloadMaxBytesValue > 0 &&
|
|
515
|
+
downloadMaxBytesValue <= Int.MAX_VALUE
|
|
516
|
+
) {
|
|
517
|
+
"downloadMaxBytes must be an integer between 1 and ${Int.MAX_VALUE}"
|
|
518
|
+
}
|
|
519
|
+
return ProbeOptions(
|
|
520
|
+
latencyUrl,
|
|
521
|
+
downloadUrl,
|
|
522
|
+
latencySamples,
|
|
523
|
+
ceil(timeoutValue).toLong(),
|
|
524
|
+
ceil(downloadMaxDurationValue).toLong(),
|
|
525
|
+
downloadMaxBytesValue.toLong(),
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private fun parseHttpUrl(rawValue: String?, optionName: String): URL {
|
|
530
|
+
val value = rawValue?.trim().orEmpty()
|
|
531
|
+
val uri = try {
|
|
532
|
+
URI(value)
|
|
533
|
+
} catch (error: Exception) {
|
|
534
|
+
throw InvalidUrlException("$optionName must be a valid HTTP or HTTPS URL", error)
|
|
535
|
+
}
|
|
536
|
+
val scheme = uri.scheme?.lowercase()
|
|
537
|
+
if ((scheme != "http" && scheme != "https") || uri.host.isNullOrBlank()) {
|
|
538
|
+
throw InvalidUrlException("$optionName must use http or https and include a host")
|
|
539
|
+
}
|
|
540
|
+
return try {
|
|
541
|
+
uri.toURL()
|
|
542
|
+
} catch (error: Exception) {
|
|
543
|
+
throw InvalidUrlException("$optionName must be a valid HTTP or HTTPS URL", error)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private fun withCacheBuster(url: URL): URL {
|
|
548
|
+
val external = url.toExternalForm()
|
|
549
|
+
val fragmentIndex = external.indexOf('#')
|
|
550
|
+
val base = if (fragmentIndex >= 0) external.substring(0, fragmentIndex) else external
|
|
551
|
+
val fragment = if (fragmentIndex >= 0) external.substring(fragmentIndex) else ""
|
|
552
|
+
val separator = if (base.contains('?')) '&' else '?'
|
|
553
|
+
@Suppress("DEPRECATION")
|
|
554
|
+
return URL("$base${separator}_nq=${UUID.randomUUID()}$fragment")
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private fun nanosToMilliseconds(nanos: Long): Double =
|
|
558
|
+
nanos.toDouble() / NANOS_PER_MILLISECOND
|
|
559
|
+
|
|
560
|
+
private fun messageFor(error: Exception): String =
|
|
561
|
+
error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
|
|
562
|
+
|
|
563
|
+
private data class ProbeOptions(
|
|
564
|
+
val latencyUrl: URL,
|
|
565
|
+
val downloadUrl: URL?,
|
|
566
|
+
val latencySamples: Int,
|
|
567
|
+
val timeoutMs: Long,
|
|
568
|
+
val downloadMaxDurationMs: Long,
|
|
569
|
+
val downloadMaxBytes: Long,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
private data class DownloadMeasurement(
|
|
573
|
+
val bytesReceived: Long,
|
|
574
|
+
val downlinkKbps: Double?,
|
|
575
|
+
val downloadError: String?,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
private data class HeaderTiming(
|
|
579
|
+
val startedNanos: Long,
|
|
580
|
+
val receivedNanos: Long,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
private class ProbeOperation(
|
|
584
|
+
private val executor: ExecutorService,
|
|
585
|
+
private val timeoutExecutor: ScheduledExecutorService,
|
|
586
|
+
private val promise: Promise,
|
|
587
|
+
) {
|
|
588
|
+
private val cancelled = AtomicBoolean(false)
|
|
589
|
+
private val timedOut = AtomicBoolean(false)
|
|
590
|
+
private val settled = AtomicBoolean(false)
|
|
591
|
+
private val activeConnection = AtomicReference<HttpURLConnection?>(null)
|
|
592
|
+
private val timeoutFuture = AtomicReference<ScheduledFuture<*>?>(null)
|
|
593
|
+
|
|
594
|
+
val isCancelled: Boolean
|
|
595
|
+
get() = cancelled.get()
|
|
596
|
+
|
|
597
|
+
val hasTimedOut: Boolean
|
|
598
|
+
get() = timedOut.get()
|
|
599
|
+
|
|
600
|
+
fun armTimeout(timeoutMs: Long) {
|
|
601
|
+
val future = timeoutExecutor.schedule(
|
|
602
|
+
{
|
|
603
|
+
timedOut.set(true)
|
|
604
|
+
activeConnection.getAndSet(null)?.disconnect()
|
|
605
|
+
executor.shutdownNow()
|
|
606
|
+
},
|
|
607
|
+
timeoutMs,
|
|
608
|
+
TimeUnit.MILLISECONDS,
|
|
609
|
+
)
|
|
610
|
+
timeoutFuture.set(future)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
fun setConnection(connection: HttpURLConnection) {
|
|
614
|
+
activeConnection.set(connection)
|
|
615
|
+
if (cancelled.get()) {
|
|
616
|
+
activeConnection.compareAndSet(connection, null)
|
|
617
|
+
connection.disconnect()
|
|
618
|
+
throw IOException("Probe cancelled")
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
fun clearConnection(connection: HttpURLConnection) {
|
|
623
|
+
activeConnection.compareAndSet(connection, null)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
fun resolve(value: Any?) {
|
|
627
|
+
if (settled.compareAndSet(false, true)) promise.resolve(value)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
fun reject(code: String, message: String?, error: Throwable? = null) {
|
|
631
|
+
if (settled.compareAndSet(false, true)) promise.reject(code, message, error)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
fun cancel(message: String, error: Throwable? = null) {
|
|
635
|
+
cancelled.set(true)
|
|
636
|
+
timeoutFuture.getAndSet(null)?.cancel(false)
|
|
637
|
+
activeConnection.getAndSet(null)?.disconnect()
|
|
638
|
+
executor.shutdownNow()
|
|
639
|
+
timeoutExecutor.shutdownNow()
|
|
640
|
+
reject(ERROR_PROBE_FAILED, message, error)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
fun finish() {
|
|
644
|
+
timeoutFuture.getAndSet(null)?.cancel(false)
|
|
645
|
+
activeConnection.getAndSet(null)?.disconnect()
|
|
646
|
+
executor.shutdown()
|
|
647
|
+
timeoutExecutor.shutdownNow()
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private class ProbeTimeoutException : IOException("Probe deadline exceeded")
|
|
652
|
+
|
|
653
|
+
private class CookieIsolationException(message: String, cause: Throwable? = null) :
|
|
654
|
+
IOException(message, cause)
|
|
655
|
+
|
|
656
|
+
private class InvalidUrlException(message: String, cause: Throwable? = null) :
|
|
657
|
+
IllegalArgumentException(message, cause)
|
|
658
|
+
|
|
659
|
+
companion object {
|
|
660
|
+
private const val ERROR_INVALID_URL = "E_INVALID_URL"
|
|
661
|
+
private const val ERROR_OFFLINE = "E_OFFLINE"
|
|
662
|
+
private const val ERROR_PROBE_TIMEOUT = "E_PROBE_TIMEOUT"
|
|
663
|
+
private const val ERROR_PROBE_FAILED = "E_PROBE_FAILED"
|
|
664
|
+
private const val NANOS_PER_MILLISECOND = 1_000_000L
|
|
665
|
+
private const val BUFFER_SIZE = 16 * 1024
|
|
666
|
+
private const val MAX_LATENCY_SAMPLES = 100
|
|
667
|
+
}
|
|
668
|
+
}
|