@onekeyfe/react-native-sni-connect 3.0.71 → 3.0.73

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.
@@ -1,5 +1,7 @@
1
1
  package com.sniconnect
2
2
 
3
+ import android.content.Context
4
+ import android.net.ConnectivityManager
3
5
  import com.facebook.react.bridge.Arguments
4
6
  import com.facebook.react.bridge.Promise
5
7
  import com.facebook.react.bridge.ReactApplicationContext
@@ -15,20 +17,59 @@ import okhttp3.Dns
15
17
  import okhttp3.Headers
16
18
  import okhttp3.MediaType.Companion.toMediaTypeOrNull
17
19
  import okhttp3.OkHttpClient
20
+ import okhttp3.Protocol
18
21
  import okhttp3.Request
19
22
  import okhttp3.RequestBody.Companion.toRequestBody
20
23
  import okhttp3.Response
21
24
  import okhttp3.ResponseBody
25
+ import okio.Buffer
22
26
  import java.io.IOException
27
+ import java.io.InterruptedIOException
23
28
  import java.net.InetAddress
29
+ import java.net.Proxy
30
+ import java.net.ProxySelector
31
+ import java.net.URI
32
+ import java.net.UnknownHostException
33
+ import java.nio.charset.StandardCharsets
34
+ import java.security.cert.CertificateException
35
+ import java.util.Collections
24
36
  import java.util.Locale
25
37
  import java.util.concurrent.ConcurrentHashMap
26
38
  import java.util.concurrent.TimeUnit
27
39
  import java.util.concurrent.atomic.AtomicBoolean
28
40
  import javax.net.ssl.HttpsURLConnection
41
+ import javax.net.ssl.SSLException
42
+ import javax.net.ssl.SSLPeerUnverifiedException
29
43
 
30
44
  private const val TAG = "SniConnect"
31
45
 
46
+ internal fun classifySniFailureCode(error: Throwable): String {
47
+ if (hasCause(error, SSLPeerUnverifiedException::class.java) ||
48
+ hasCause(error, CertificateException::class.java)
49
+ ) {
50
+ return "SNI_CERT_FAILED"
51
+ }
52
+ if (hasCause(error, UnknownHostException::class.java)) {
53
+ return "SNI_SECURITY_POLICY_FAILED"
54
+ }
55
+ if (hasCause(error, InterruptedIOException::class.java)) {
56
+ return "SNI_REQUEST_TIMEOUT"
57
+ }
58
+ if (hasCause(error, SSLException::class.java)) {
59
+ return "SNI_TLS_FAILED"
60
+ }
61
+ return "SNI_REQUEST_FAILED"
62
+ }
63
+
64
+ private fun hasCause(error: Throwable, type: Class<out Throwable>): Boolean {
65
+ var current: Throwable? = error
66
+ while (current != null) {
67
+ if (type.isInstance(current)) return true
68
+ current = current.cause
69
+ }
70
+ return false
71
+ }
72
+
32
73
  @ReactModule(name = SniConnectModule.NAME)
33
74
  class SniConnectModule(reactContext: ReactApplicationContext) :
34
75
  NativeSniConnectSpec(reactContext) {
@@ -42,7 +83,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
42
83
 
43
84
  // A single dispatcher + connection pool shared across all cached clients so we
44
85
  // don't spawn a thread pool / connection pool per (hostname, ip) pair.
45
- private val sharedDispatcher = Dispatcher()
86
+ private val sharedDispatcher = Dispatcher().apply {
87
+ maxRequests = 64
88
+ maxRequestsPerHost = 64
89
+ }
46
90
  private val sharedConnectionPool = ConnectionPool()
47
91
  }
48
92
 
@@ -63,11 +107,27 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
63
107
  // LinkedHashMap is not thread-safe.
64
108
  private val clientCache = object : LinkedHashMap<ClientKey, OkHttpClient>(16, 0.75f, true) {
65
109
  override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ClientKey, OkHttpClient>): Boolean {
66
- return size > MAX_CLIENTS
110
+ val shouldEvict = size > MAX_CLIENTS
111
+ if (shouldEvict) {
112
+ SniConnectLogger.info(
113
+ SniConnectLogger.event(
114
+ "sni_cache_evict",
115
+ "hostname" to eldest.key.hostname,
116
+ "ipHash" to SniConnectLogger.shortHash(eldest.key.ip),
117
+ "cacheSize" to size,
118
+ "limit" to MAX_CLIENTS,
119
+ "reason" to "max_cached_clients",
120
+ ),
121
+ )
122
+ }
123
+ return shouldEvict
67
124
  }
68
125
  }
69
126
 
70
127
  private val activeCalls = ConcurrentHashMap<String, Call>()
128
+ private val allActiveCalls = Collections.newSetFromMap(ConcurrentHashMap<Call, Boolean>())
129
+ private val activeCallsLock = Any()
130
+ private val requestLimiter = SniConnectRequestLimiter()
71
131
 
72
132
  override fun getName(): String = NAME
73
133
 
@@ -76,29 +136,62 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
76
136
  val requestConfig = config.toRequestConfig()
77
137
  performRequest(requestConfig, promise)
78
138
  } catch (error: Exception) {
79
- SniConnectLogger.error("Config parsing failed: ${error.message}")
139
+ SniConnectLogger.error(
140
+ SniConnectLogger.event(
141
+ "sni_request_result",
142
+ "result" to "error",
143
+ "code" to "SNI_INVALID_CONFIG",
144
+ "nativeErrorClass" to error.javaClass.simpleName,
145
+ "stage" to "parse_config",
146
+ ),
147
+ )
80
148
  promise.reject("SNI_INVALID_CONFIG", error.message, error)
81
149
  }
82
150
  }
83
151
 
84
152
  @ReactMethod
85
153
  override fun cancelRequest(requestId: String, promise: Promise) {
86
- val call = activeCalls.remove(requestId)
154
+ val call = synchronized(activeCallsLock) {
155
+ activeCalls.remove(requestId)
156
+ }
87
157
  if (call != null) {
88
158
  call.cancel()
89
- SniConnectLogger.info("Cancelled request: $requestId")
159
+ SniConnectLogger.info(
160
+ SniConnectLogger.event(
161
+ "sni_cancel",
162
+ "requestIdHash" to SniConnectLogger.shortHash(requestId),
163
+ "success" to true,
164
+ ),
165
+ )
90
166
  promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
91
167
  } else {
168
+ SniConnectLogger.warn(
169
+ SniConnectLogger.event(
170
+ "sni_cancel",
171
+ "requestIdHash" to SniConnectLogger.shortHash(requestId),
172
+ "success" to false,
173
+ ),
174
+ )
92
175
  promise.resolve(Arguments.createMap().apply { putBoolean("success", false) })
93
176
  }
94
177
  }
95
178
 
96
179
  @ReactMethod
97
180
  override fun cancelAllRequests(promise: Promise) {
98
- val count = activeCalls.size
99
- activeCalls.forEach { (_, call) -> call.cancel() }
100
- activeCalls.clear()
101
- SniConnectLogger.info("Cancelled $count active requests")
181
+ val calls = synchronized(activeCallsLock) {
182
+ val snapshot = allActiveCalls.toList()
183
+ activeCalls.clear()
184
+ allActiveCalls.clear()
185
+ snapshot
186
+ }
187
+ calls.forEach { call -> call.cancel() }
188
+ SniConnectLogger.info(
189
+ SniConnectLogger.event(
190
+ "sni_cancel_all",
191
+ "cancelledCount" to calls.size,
192
+ "success" to true,
193
+ ),
194
+ )
102
195
  promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
103
196
  }
104
197
 
@@ -109,12 +202,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
109
202
  }
110
203
  // Drop pinned-IP connections from the shared pool.
111
204
  sharedConnectionPool.evictAll()
112
- SniConnectLogger.info("DNS cache cleared")
205
+ SniConnectLogger.info(
206
+ SniConnectLogger.event(
207
+ "sni_lifecycle",
208
+ "action" to "clear_dns_cache",
209
+ "success" to true,
210
+ ),
211
+ )
113
212
  promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
114
213
  }
115
214
 
215
+ @ReactMethod
216
+ override fun isProxyActiveForUrl(url: String, promise: Promise) {
217
+ try {
218
+ promise.resolve(isProxyActiveForUrl(url))
219
+ } catch (error: Exception) {
220
+ promise.reject("SNI_INVALID_URL", error.message, error)
221
+ }
222
+ }
223
+
116
224
  private fun performRequest(config: RequestConfig, promise: Promise) {
225
+ val startedAtMs = android.os.SystemClock.elapsedRealtime()
226
+ var requestSlot: SniConnectRequestLimiter.Token? = null
227
+ var registeredCall: Call? = null
117
228
  try {
229
+ requestSlot = requestLimiter.acquire(config.hostname, config.ip)
118
230
  val client = getOrCreateClient(config)
119
231
  val request = buildRequest(config)
120
232
  val call = client.newCall(request)
@@ -122,59 +234,167 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
122
234
  // Apply per-request timeout
123
235
  call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
124
236
 
125
- // Register the call if requestId is provided
126
- config.requestId?.let { requestId ->
127
- activeCalls[requestId] = call
128
- }
237
+ registerCall(config.requestId, call)
238
+ registeredCall = call
239
+
240
+ SniConnectLogger.info(
241
+ SniConnectLogger.event(
242
+ "sni_request_start",
243
+ "requestIdHash" to SniConnectLogger.shortHash(config.requestId),
244
+ "hostname" to config.hostname.lowercase(Locale.US),
245
+ "ipHash" to SniConnectLogger.shortHash(config.ip),
246
+ "ipFamily" to SniConnectLogger.ipFamily(config.ip),
247
+ "method" to config.method,
248
+ "timeoutMs" to config.timeoutMillis,
249
+ "headerCount" to config.headers.size,
250
+ "bodyBytes" to (config.body?.toByteArray(StandardCharsets.UTF_8)?.size ?: 0),
251
+ ),
252
+ )
129
253
 
130
254
  // Guard against double-settling the promise (RN hard-crashes otherwise).
131
255
  val settled = AtomicBoolean(false)
132
256
 
133
257
  call.enqueue(object : Callback {
134
258
  override fun onFailure(call: Call, e: IOException) {
135
- config.requestId?.let { activeCalls.remove(it) }
259
+ unregisterCall(config.requestId, call)
260
+ requestSlot?.release()
136
261
  if (!settled.compareAndSet(false, true)) return
137
262
 
138
263
  if (call.isCanceled()) {
139
264
  promise.reject("SNI_CANCELLED", "Request cancelled", null)
140
265
  } else {
141
- SniConnectLogger.error("Request failed: ${e.message}")
142
- promise.reject("SNI_REQUEST_FAILED", e.message, e)
266
+ val code = classifySniFailureCode(e)
267
+ SniConnectLogger.error(
268
+ SniConnectLogger.event(
269
+ "sni_request_result",
270
+ "result" to "error",
271
+ "code" to code,
272
+ "nativeErrorClass" to e.javaClass.simpleName,
273
+ "requestIdHash" to SniConnectLogger.shortHash(config.requestId),
274
+ "hostname" to config.hostname.lowercase(Locale.US),
275
+ "ipHash" to SniConnectLogger.shortHash(config.ip),
276
+ "ipFamily" to SniConnectLogger.ipFamily(config.ip),
277
+ "method" to config.method,
278
+ "timeoutMs" to config.timeoutMillis,
279
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
280
+ ),
281
+ )
282
+ promise.reject(code, e.message, e)
143
283
  }
144
284
  }
145
285
 
146
286
  override fun onResponse(call: Call, response: Response) {
147
- config.requestId?.let { activeCalls.remove(it) }
148
-
149
- val result: WritableMap = try {
150
- response.use {
151
- val bodyString = response.body.safeString()
152
- val headerMap = headersToMap(response.headers)
287
+ try {
288
+ response.use { currentResponse ->
289
+ val bodyString = currentResponse.body.safeString()
290
+ val headerMaps = headersToMaps(currentResponse.headers)
291
+ val resultLog = SniConnectLogger.event(
292
+ "sni_request_result",
293
+ "result" to "response",
294
+ "status" to currentResponse.code,
295
+ "requestIdHash" to SniConnectLogger.shortHash(config.requestId),
296
+ "hostname" to config.hostname.lowercase(Locale.US),
297
+ "ipHash" to SniConnectLogger.shortHash(config.ip),
298
+ "ipFamily" to SniConnectLogger.ipFamily(config.ip),
299
+ "method" to config.method,
300
+ "timeoutMs" to config.timeoutMillis,
301
+ "responseBytes" to bodyString.toByteArray(StandardCharsets.UTF_8).size,
302
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
303
+ )
153
304
  Arguments.createMap().apply {
154
305
  putString("data", bodyString)
155
- putInt("status", response.code)
156
- putString("statusText", response.message)
157
- putMap("headers", headerMap.toWritableMap())
306
+ putInt("status", currentResponse.code)
307
+ putString("statusText", currentResponse.message)
308
+ putMap("headers", headerMaps.singleValueHeaders.toWritableMap())
309
+ putMap("multiValueHeaders", headerMaps.multiValueHeaders.toWritableArrayMap())
310
+ }.also { result ->
311
+ if (currentResponse.code >= 400) {
312
+ SniConnectLogger.warn(resultLog)
313
+ } else {
314
+ SniConnectLogger.info(resultLog)
315
+ }
316
+ if (settled.compareAndSet(false, true)) {
317
+ promise.resolve(result)
318
+ }
158
319
  }
159
320
  }
160
321
  } catch (error: Exception) {
161
322
  if (!settled.compareAndSet(false, true)) return
162
- SniConnectLogger.error("Response processing failed: ${error.message}")
323
+ SniConnectLogger.error(
324
+ SniConnectLogger.event(
325
+ "sni_request_result",
326
+ "result" to "error",
327
+ "code" to "SNI_RESPONSE_FAILED",
328
+ "nativeErrorClass" to error.javaClass.simpleName,
329
+ "requestIdHash" to SniConnectLogger.shortHash(config.requestId),
330
+ "hostname" to config.hostname.lowercase(Locale.US),
331
+ "ipHash" to SniConnectLogger.shortHash(config.ip),
332
+ "ipFamily" to SniConnectLogger.ipFamily(config.ip),
333
+ "method" to config.method,
334
+ "timeoutMs" to config.timeoutMillis,
335
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
336
+ ),
337
+ )
163
338
  promise.reject("SNI_RESPONSE_FAILED", error.message, error)
164
- return
165
- }
166
-
167
- if (response.code >= 400) {
168
- SniConnectLogger.warn("HTTP ${response.code} for ${config.hostname}")
169
- }
170
- if (settled.compareAndSet(false, true)) {
171
- promise.resolve(result)
339
+ } finally {
340
+ unregisterCall(config.requestId, call)
341
+ requestSlot?.release()
172
342
  }
173
343
  }
174
344
  })
175
345
  } catch (error: Exception) {
176
- SniConnectLogger.error("Request setup failed: ${error.message}")
177
- promise.reject("SNI_REQUEST_FAILED", error.message, error)
346
+ registeredCall?.let { call ->
347
+ unregisterCall(config.requestId, call)
348
+ }
349
+ requestSlot?.release()
350
+ val code = if (error is SniConnectValidation.ValidationException) {
351
+ "SNI_RESOURCE_LIMIT"
352
+ } else {
353
+ "SNI_REQUEST_FAILED"
354
+ }
355
+ SniConnectLogger.error(
356
+ SniConnectLogger.event(
357
+ "sni_request_result",
358
+ "result" to "error",
359
+ "code" to code,
360
+ "nativeErrorClass" to error.javaClass.simpleName,
361
+ "requestIdHash" to SniConnectLogger.shortHash(config.requestId),
362
+ "hostname" to config.hostname.lowercase(Locale.US),
363
+ "ipHash" to SniConnectLogger.shortHash(config.ip),
364
+ "ipFamily" to SniConnectLogger.ipFamily(config.ip),
365
+ "method" to config.method,
366
+ "timeoutMs" to config.timeoutMillis,
367
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
368
+ ),
369
+ )
370
+ promise.reject(code, error.message, error)
371
+ }
372
+ }
373
+
374
+ private fun registerCall(requestId: String?, call: Call) {
375
+ val previousCall = synchronized(activeCallsLock) {
376
+ val previous = requestId?.let { activeCalls.put(it, call) }
377
+ allActiveCalls.add(call)
378
+ previous
379
+ }
380
+ if (previousCall != null && previousCall != call) {
381
+ previousCall.cancel()
382
+ SniConnectLogger.warn(
383
+ SniConnectLogger.event(
384
+ "sni_duplicate_request_id",
385
+ "requestIdHash" to SniConnectLogger.shortHash(requestId),
386
+ "action" to "cancel_previous",
387
+ ),
388
+ )
389
+ }
390
+ }
391
+
392
+ private fun unregisterCall(requestId: String?, call: Call) {
393
+ synchronized(activeCallsLock) {
394
+ if (requestId != null) {
395
+ activeCalls.remove(requestId, call)
396
+ }
397
+ allActiveCalls.remove(call)
178
398
  }
179
399
  }
180
400
 
@@ -185,16 +405,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
185
405
  synchronized(clientCache) {
186
406
  clientCache[key]?.let { return it }
187
407
 
188
- // 60s defaults at the client level; the real deadline is the per-call timeout.
189
- val defaultTimeout = 60_000L
190
-
191
408
  val client = OkHttpClient.Builder()
192
409
  .dispatcher(sharedDispatcher)
193
410
  .connectionPool(sharedConnectionPool)
194
- .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
195
- .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
196
- .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
411
+ .proxy(Proxy.NO_PROXY)
412
+ .protocols(listOf(Protocol.HTTP_1_1))
413
+ .connectTimeout(0, TimeUnit.MILLISECONDS)
414
+ .readTimeout(0, TimeUnit.MILLISECONDS)
415
+ .writeTimeout(0, TimeUnit.MILLISECONDS)
197
416
  .callTimeout(0, TimeUnit.MILLISECONDS)
417
+ .followRedirects(false)
418
+ .followSslRedirects(false)
198
419
  // TLS is validated normally: cert chain via the default trust manager and
199
420
  // hostname verification against the REAL hostname (not the pinned IP).
200
421
  .hostnameVerifier { _, session ->
@@ -204,6 +425,19 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
204
425
  .build()
205
426
 
206
427
  clientCache[key] = client
428
+ SniConnectLogger.info(
429
+ SniConnectLogger.event(
430
+ "sni_transport_config",
431
+ "hostname" to key.hostname,
432
+ "ipHash" to SniConnectLogger.shortHash(key.ip),
433
+ "ipFamily" to SniConnectLogger.ipFamily(key.ip),
434
+ "proxyMode" to "no_proxy",
435
+ "pinnedResolver" to true,
436
+ "protocol" to "http1",
437
+ "followRedirects" to false,
438
+ "cacheSize" to clientCache.size,
439
+ ),
440
+ )
207
441
  return client
208
442
  }
209
443
  }
@@ -218,7 +452,15 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
218
452
  return if (requestedHost.lowercase(Locale.US) == expectedHost) {
219
453
  listOf(pinnedAddress)
220
454
  } else {
221
- Dns.SYSTEM.lookup(requestedHost)
455
+ SniConnectLogger.warn(
456
+ SniConnectLogger.event(
457
+ "sni_pinned_dns_unexpected_host",
458
+ "expectedHost" to expectedHost,
459
+ "requestedHostHash" to SniConnectLogger.shortHash(requestedHost.lowercase(Locale.US)),
460
+ "result" to "fail_closed",
461
+ ),
462
+ )
463
+ throw UnknownHostException("Unexpected host for pinned SNI request: $requestedHost")
222
464
  }
223
465
  }
224
466
  }
@@ -232,30 +474,35 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
232
474
  val builder = Request.Builder().url(url)
233
475
 
234
476
  config.headers.forEach { (key, value) ->
235
- if (!key.equals("host", ignoreCase = true)) {
236
- builder.addHeader(key, value)
237
- }
477
+ builder.addHeader(key, value)
238
478
  }
239
479
  builder.header("Host", config.hostname)
480
+ builder.header("Accept-Encoding", "identity")
240
481
 
241
482
  val method = config.method
242
- val bodyContent = config.body ?: ""
243
- val mediaType = config.headers.entries
244
- .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
245
- ?.value
246
- ?.toMediaTypeOrNull()
247
- ?: "application/json; charset=utf-8".toMediaTypeOrNull()
483
+ val requestBody = config.body?.let { bodyContent ->
484
+ val mediaType = config.headers.entries
485
+ .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
486
+ ?.value
487
+ ?.toMediaTypeOrNull()
488
+ bodyContent.toRequestBody(mediaType)
489
+ }
248
490
 
249
491
  when (method) {
250
492
  "GET" -> builder.get()
251
493
  "HEAD" -> builder.head()
494
+ "DELETE" -> {
495
+ if (requestBody == null) {
496
+ builder.delete()
497
+ } else {
498
+ builder.delete(requestBody)
499
+ }
500
+ }
252
501
  else -> {
253
- val requestBody = bodyContent.toRequestBody(mediaType)
254
502
  when (method) {
255
- "POST" -> builder.post(requestBody)
256
- "PUT" -> builder.put(requestBody)
257
- "PATCH" -> builder.patch(requestBody)
258
- "DELETE" -> builder.delete(requestBody)
503
+ "POST" -> builder.post(requireNotNull(requestBody))
504
+ "PUT" -> builder.put(requireNotNull(requestBody))
505
+ "PATCH" -> builder.patch(requireNotNull(requestBody))
259
506
  else -> builder.method(method, requestBody)
260
507
  }
261
508
  }
@@ -267,18 +514,36 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
267
514
  private fun ResponseBody?.safeString(): String {
268
515
  if (this == null) return ""
269
516
  return try {
270
- this.string()
517
+ val source = source()
518
+ val buffer = Buffer()
519
+ var totalBytes = 0L
520
+ while (true) {
521
+ val read = source.read(buffer, 8 * 1024)
522
+ if (read == -1L) break
523
+ totalBytes += read
524
+ if (totalBytes > SniConnectValidation.MAX_RESPONSE_BODY_BYTES) {
525
+ throw IOException("Response body too large")
526
+ }
527
+ }
528
+ val charset = contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
529
+ buffer.readString(charset)
271
530
  } catch (error: IOException) {
272
531
  throw IOException("Failed to read response body", error)
273
532
  }
274
533
  }
275
534
 
276
- private fun headersToMap(headers: Headers): Map<String, String> {
277
- val map = mutableMapOf<String, String>()
278
- for (name in headers.names()) {
279
- map[name] = headers[name] ?: ""
535
+ private fun headersToMaps(headers: Headers): HeaderMaps {
536
+ val singleValueHeaders = linkedMapOf<String, String>()
537
+ val multiValueHeaders = linkedMapOf<String, MutableList<String>>()
538
+
539
+ for (index in 0 until headers.size) {
540
+ val name = headers.name(index).lowercase(Locale.US)
541
+ val value = headers.value(index)
542
+ singleValueHeaders[name] = value
543
+ multiValueHeaders.getOrPut(name) { mutableListOf() }.add(value)
280
544
  }
281
- return map
545
+
546
+ return HeaderMaps(singleValueHeaders, multiValueHeaders)
282
547
  }
283
548
 
284
549
  private fun Map<String, String>.toWritableMap(): WritableMap {
@@ -287,8 +552,18 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
287
552
  }
288
553
  }
289
554
 
555
+ private fun Map<String, List<String>>.toWritableArrayMap(): WritableMap {
556
+ return Arguments.createMap().apply {
557
+ forEach { (key, values) ->
558
+ val array = Arguments.createArray()
559
+ values.forEach { value -> array.pushString(value) }
560
+ putArray(key, array)
561
+ }
562
+ }
563
+ }
564
+
290
565
  private fun ReadableMap.toRequestConfig(): RequestConfig {
291
- val headersMap = if (hasKey("headers") && !isNull("headers")) {
566
+ val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
292
567
  getMap("headers")?.toHashMap()
293
568
  ?.mapValues { (_, value) -> value?.toString() ?: "" }
294
569
  ?: emptyMap()
@@ -297,7 +572,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
297
572
  }
298
573
 
299
574
  val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
300
- getDouble("timeout").toLong().coerceAtLeast(1L)
575
+ SniConnectValidation.parseTimeoutMillis(getDouble("timeout"))
301
576
  } else {
302
577
  30_000L
303
578
  }
@@ -308,13 +583,18 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
308
583
  val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
309
584
  val method = getString("method") ?: "GET"
310
585
  val path = getString("path") ?: "/"
586
+ val body = if (hasKey("body") && !isNull("body")) getString("body") else null
311
587
 
312
588
  // Validate every caller-controlled field at the boundary.
589
+ SniConnectValidation.validateRequestId(requestId)
313
590
  SniConnectValidation.validatePublicIp(ip)
314
591
  SniConnectValidation.validateHostname(hostname)
315
- SniConnectValidation.validateHeaders(headersMap)
592
+ val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
316
593
  val normalizedMethod = SniConnectValidation.normalizeMethod(method)
317
594
  val normalizedPath = SniConnectValidation.normalizePath(path)
595
+ SniConnectValidation.validateTimeout(timeoutMillis)
596
+ SniConnectValidation.validateBody(body)
597
+ SniConnectValidation.validateMethodBody(normalizedMethod, body)
318
598
 
319
599
  return RequestConfig(
320
600
  requestId = requestId,
@@ -323,7 +603,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
323
603
  method = normalizedMethod,
324
604
  path = normalizedPath,
325
605
  headers = headersMap,
326
- body = if (hasKey("body") && !isNull("body")) getString("body") else null,
606
+ body = body,
327
607
  timeoutMillis = timeoutMillis,
328
608
  )
329
609
  }
@@ -338,4 +618,164 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
338
618
  val body: String?,
339
619
  val timeoutMillis: Long,
340
620
  )
621
+
622
+ private data class HeaderMaps(
623
+ val singleValueHeaders: Map<String, String>,
624
+ val multiValueHeaders: Map<String, List<String>>,
625
+ )
626
+
627
+ private fun isProxyActiveForUrl(url: String): Boolean {
628
+ val startedAtMs = android.os.SystemClock.elapsedRealtime()
629
+ val uri = try {
630
+ URI(url)
631
+ } catch (error: Exception) {
632
+ SniConnectLogger.warn(
633
+ SniConnectLogger.event(
634
+ "proxy_preflight",
635
+ "platform" to "android",
636
+ "scheme" to "unknown",
637
+ "host" to "unknown",
638
+ "result" to "invalid_url",
639
+ "source" to "validation",
640
+ "proxyTypeCount" to 0,
641
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
642
+ ),
643
+ )
644
+ throw error
645
+ }
646
+ val scheme = uri.scheme?.lowercase(Locale.US)
647
+ ?: run {
648
+ SniConnectLogger.warn(
649
+ SniConnectLogger.event(
650
+ "proxy_preflight",
651
+ "platform" to "android",
652
+ "scheme" to "unknown",
653
+ "host" to "unknown",
654
+ "result" to "invalid_url",
655
+ "source" to "validation",
656
+ "proxyTypeCount" to 0,
657
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
658
+ ),
659
+ )
660
+ throw IllegalArgumentException("URL must include a scheme")
661
+ }
662
+ if (scheme != "http" && scheme != "https") {
663
+ SniConnectLogger.warn(
664
+ SniConnectLogger.event(
665
+ "proxy_preflight",
666
+ "platform" to "android",
667
+ "scheme" to scheme,
668
+ "host" to "unknown",
669
+ "result" to "invalid_url",
670
+ "source" to "validation",
671
+ "proxyTypeCount" to 0,
672
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
673
+ ),
674
+ )
675
+ throw IllegalArgumentException("Only http and https URLs are supported")
676
+ }
677
+ val host = uri.host
678
+ if (host.isNullOrBlank()) {
679
+ SniConnectLogger.warn(
680
+ SniConnectLogger.event(
681
+ "proxy_preflight",
682
+ "platform" to "android",
683
+ "scheme" to scheme,
684
+ "host" to "unknown",
685
+ "result" to "invalid_url",
686
+ "source" to "validation",
687
+ "proxyTypeCount" to 0,
688
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
689
+ ),
690
+ )
691
+ throw IllegalArgumentException("URL must include a host")
692
+ }
693
+
694
+ val selector = ProxySelector.getDefault()
695
+ val selectorProxies = selector?.select(uri).orEmpty()
696
+ val selectorHasProxy = selectorProxies.any { proxy ->
697
+ proxy != Proxy.NO_PROXY && proxy.type() != Proxy.Type.DIRECT
698
+ }
699
+ if (selectorHasProxy) {
700
+ SniConnectLogger.info(
701
+ SniConnectLogger.event(
702
+ "proxy_preflight",
703
+ "platform" to "android",
704
+ "scheme" to scheme,
705
+ "host" to host,
706
+ "result" to true,
707
+ "source" to "ProxySelector",
708
+ "proxyTypeCount" to selectorProxies.map { proxy -> proxy.type().name }.toSet().size,
709
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
710
+ ),
711
+ )
712
+ return true
713
+ }
714
+
715
+ val connectivityManager = reactApplicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
716
+ as? ConnectivityManager
717
+ ?: run {
718
+ SniConnectLogger.info(
719
+ SniConnectLogger.event(
720
+ "proxy_preflight",
721
+ "platform" to "android",
722
+ "scheme" to scheme,
723
+ "host" to host,
724
+ "result" to false,
725
+ "source" to "none",
726
+ "proxyTypeCount" to selectorProxies.size,
727
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
728
+ ),
729
+ )
730
+ return false
731
+ }
732
+ val activeNetworkProxy = connectivityManager.activeNetwork
733
+ ?.let { network -> connectivityManager.getLinkProperties(network)?.httpProxy }
734
+ if (activeNetworkProxy?.host?.isNotBlank() == true && activeNetworkProxy.port > 0) {
735
+ SniConnectLogger.info(
736
+ SniConnectLogger.event(
737
+ "proxy_preflight",
738
+ "platform" to "android",
739
+ "scheme" to scheme,
740
+ "host" to host,
741
+ "result" to true,
742
+ "source" to "LinkProperties",
743
+ "proxyTypeCount" to selectorProxies.size,
744
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
745
+ ),
746
+ )
747
+ return true
748
+ }
749
+
750
+ val defaultProxy = connectivityManager.defaultProxy
751
+ if (defaultProxy?.host?.isNotBlank() == true && defaultProxy.port > 0) {
752
+ SniConnectLogger.info(
753
+ SniConnectLogger.event(
754
+ "proxy_preflight",
755
+ "platform" to "android",
756
+ "scheme" to scheme,
757
+ "host" to host,
758
+ "result" to true,
759
+ "source" to "defaultProxy",
760
+ "proxyTypeCount" to selectorProxies.size,
761
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
762
+ ),
763
+ )
764
+ return true
765
+ }
766
+
767
+ SniConnectLogger.info(
768
+ SniConnectLogger.event(
769
+ "proxy_preflight",
770
+ "platform" to "android",
771
+ "scheme" to scheme,
772
+ "host" to host,
773
+ "result" to false,
774
+ "source" to "none",
775
+ "proxyTypeCount" to selectorProxies.size,
776
+ "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
777
+ ),
778
+ )
779
+ return false
780
+ }
341
781
  }