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,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
+ }