@mentra/crust 3.2.1 → 3.3.0-beta.1001
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/README.md +27 -0
- package/android/build.gradle +24 -9
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +44 -15
- package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
- package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +69 -54
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
- package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +4 -0
- package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
- package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +199 -0
- package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
- package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
- package/build/Crust.types.d.ts +25 -11
- package/build/Crust.types.d.ts.map +1 -1
- package/build/Crust.types.js.map +1 -1
- package/build/CrustModule.d.ts +9 -2
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +3 -1
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +20 -8
- package/build/CrustModule.web.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/nativeHttpRequest.d.ts +13 -0
- package/build/nativeHttpRequest.d.ts.map +1 -0
- package/build/nativeHttpRequest.js +51 -0
- package/build/nativeHttpRequest.js.map +1 -0
- package/build/nativeHttpRequest.test.d.ts +2 -0
- package/build/nativeHttpRequest.test.d.ts.map +1 -0
- package/build/nativeHttpRequest.test.js +88 -0
- package/build/nativeHttpRequest.test.js.map +1 -0
- package/ios/Crust.podspec +1 -1
- package/ios/CrustModule.swift +18 -7
- package/ios/PermissionStatusReceipt.swift +105 -0
- package/ios/Resources/startup.js +154 -68
- package/ios/Source/JSCPolyfillBridge.swift +31 -14
- package/package.json +2 -2
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +2 -0
- package/plugin/build/withAndroid.js +39 -0
- package/src/Crust.types.ts +26 -12
- package/src/CrustModule.ts +9 -1
- package/src/CrustModule.web.ts +18 -7
- package/src/index.ts +1 -0
- package/src/nativeHttpRequest.test.ts +94 -0
- package/src/nativeHttpRequest.ts +74 -0
- package/android/src/internal/AndroidManifest.xml +0 -9
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.mentra.crust.jsc
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.os.SystemClock
|
|
4
5
|
import android.util.Log
|
|
5
6
|
import com.dokar.quickjs.QuickJs
|
|
6
7
|
import com.dokar.quickjs.binding.function
|
|
@@ -11,6 +12,7 @@ import java.util.concurrent.Executors
|
|
|
11
12
|
import java.util.concurrent.ScheduledExecutorService
|
|
12
13
|
import java.util.concurrent.ScheduledFuture
|
|
13
14
|
import java.util.concurrent.TimeUnit
|
|
15
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
14
16
|
import kotlinx.coroutines.Dispatchers
|
|
15
17
|
import kotlinx.coroutines.runBlocking
|
|
16
18
|
|
|
@@ -133,6 +135,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
133
135
|
val dispatcher: JSCDispatcher = JSCDispatcher(appContext)
|
|
134
136
|
|
|
135
137
|
private val contexts = ConcurrentHashMap<String, ContextRecord>()
|
|
138
|
+
private val nextContextId = AtomicLong()
|
|
136
139
|
private val polyfillBundle: String by lazy { loadPolyfillBundle() }
|
|
137
140
|
|
|
138
141
|
// Schedule pool for timers; per-context the executor is the same as the
|
|
@@ -146,6 +149,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
146
149
|
val packageName: String,
|
|
147
150
|
val qjs: QuickJs,
|
|
148
151
|
val executor: ExecutorService,
|
|
152
|
+
val contextId: Long,
|
|
149
153
|
val pendingTimers: MutableMap<Int, ScheduledFuture<*>> = ConcurrentHashMap(),
|
|
150
154
|
@Volatile var readyAcked: Boolean = false,
|
|
151
155
|
@Volatile var readyNackTimer: ScheduledFuture<*>? = null,
|
|
@@ -273,6 +277,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
273
277
|
packageName = packageName,
|
|
274
278
|
qjs = qjs,
|
|
275
279
|
executor = executor,
|
|
280
|
+
contextId = nextContextId.incrementAndGet(),
|
|
276
281
|
)
|
|
277
282
|
contexts[packageName] = record
|
|
278
283
|
|
|
@@ -288,10 +293,10 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
288
293
|
runBlocking {
|
|
289
294
|
installGlobals(qjs, packageName)
|
|
290
295
|
if (bundle.isNotEmpty()) {
|
|
291
|
-
qjs.
|
|
296
|
+
qjs.evaluateForEffect(bundle, filename = "mentrajs:startup.js")
|
|
292
297
|
}
|
|
293
298
|
if (miniappJs.isNotEmpty()) {
|
|
294
|
-
qjs.
|
|
299
|
+
qjs.evaluateForEffect(miniappJs, filename = "mentrajs:miniapp.js")
|
|
295
300
|
}
|
|
296
301
|
}
|
|
297
302
|
Log.i(TAG, "spawned $packageName")
|
|
@@ -415,7 +420,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
415
420
|
record.executor.submit {
|
|
416
421
|
runBlocking {
|
|
417
422
|
try {
|
|
418
|
-
record.qjs.
|
|
423
|
+
record.qjs.evaluateForEffect(
|
|
419
424
|
"globalThis.__deliverTimer && globalThis.__deliverTimer($token);",
|
|
420
425
|
filename = "mentrajs:timer-$token.js",
|
|
421
426
|
)
|
|
@@ -457,10 +462,23 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
457
462
|
* Push a `{kind: "event"|"response", …}` envelope into the named
|
|
458
463
|
* context's globalThis.__deliver. Hops onto the per-context executor.
|
|
459
464
|
*/
|
|
460
|
-
fun dispatchToJs(packageName: String, envelopeJson: String) {
|
|
461
|
-
val record = contexts[packageName]
|
|
465
|
+
fun dispatchToJs(packageName: String, envelopeJson: String, deliveryId: Long? = null) {
|
|
466
|
+
val record = contexts[packageName]
|
|
467
|
+
// Only host-generated PINGs carry this ID. Ordinary media/event deliveries stay quiet.
|
|
468
|
+
val diagnostic = deliveryId?.let {
|
|
469
|
+
JSDeliveryDiagnostics(it, record?.contextId, SystemClock::elapsedRealtime) { metadata ->
|
|
470
|
+
reportDeliveryDiagnostic(packageName, metadata)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
diagnostic?.entry()
|
|
474
|
+
if (record == null) {
|
|
475
|
+
diagnostic?.dropped("context-missing")
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
diagnostic?.queued()
|
|
462
479
|
try {
|
|
463
480
|
record.executor.submit {
|
|
481
|
+
diagnostic?.started()
|
|
464
482
|
// Arm only once evaluate is about to run. Queue wait during a
|
|
465
483
|
// long previous turn (WHIP start, etc.) must not count — that
|
|
466
484
|
// was firing ready_nack while the context was merely busy.
|
|
@@ -489,14 +507,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
489
507
|
kill(packageName)
|
|
490
508
|
}, WATCHDOG_KILL_MS, TimeUnit.MILLISECONDS)
|
|
491
509
|
record.watchdogTimer = killTimer
|
|
510
|
+
var failure: Throwable? = null
|
|
492
511
|
try {
|
|
493
512
|
val source = "globalThis.__deliver(${jsStringLiteral(envelopeJson)});"
|
|
494
513
|
runBlocking {
|
|
495
|
-
record.qjs.
|
|
514
|
+
record.qjs.evaluateForEffect(source, filename = "mentrajs:deliver.js")
|
|
496
515
|
}
|
|
497
516
|
} catch (e: Throwable) {
|
|
517
|
+
failure = e
|
|
498
518
|
Log.w(TAG, "dispatchToJs threw in $packageName: ${e.message}", e)
|
|
519
|
+
if (diagnostic == null) {
|
|
520
|
+
reportDeliveryDiagnostic(packageName, mapOf(
|
|
521
|
+
"event" to "native-delivery-failed",
|
|
522
|
+
"contextId" to record.contextId,
|
|
523
|
+
"phase" to "evaluate-failed",
|
|
524
|
+
"errorClass" to e.javaClass.simpleName,
|
|
525
|
+
"causeClass" to e.cause?.javaClass?.simpleName,
|
|
526
|
+
))
|
|
527
|
+
}
|
|
499
528
|
} finally {
|
|
529
|
+
diagnostic?.finished(failure)
|
|
500
530
|
// Success or throw: the host observed the turn end.
|
|
501
531
|
record.readyNackTimer?.cancel(false)
|
|
502
532
|
record.readyNackTimer = null
|
|
@@ -505,8 +535,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
505
535
|
if (record.watchdogTimer === killTimer) record.watchdogTimer = null
|
|
506
536
|
}
|
|
507
537
|
}
|
|
508
|
-
} catch (
|
|
509
|
-
|
|
538
|
+
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
|
539
|
+
diagnostic?.dropped("executor-rejected", e)
|
|
540
|
+
Log.w(TAG, "dispatchToJs rejected for $packageName", e)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private fun reportDeliveryDiagnostic(packageName: String, metadata: Map<String, Any?>) {
|
|
545
|
+
val line = org.json.JSONObject(metadata as Map<*, *>).toString()
|
|
546
|
+
Log.i(TAG, line)
|
|
547
|
+
// Use the existing miniapp log route so console interception includes this in reports.
|
|
548
|
+
try {
|
|
549
|
+
deliverOrDrop(OutboundMessage(packageName, mapOf(
|
|
550
|
+
"packageName" to packageName,
|
|
551
|
+
"iface" to "__log",
|
|
552
|
+
"method" to if (metadata["errorClass"] == null) "log" else "warn",
|
|
553
|
+
"argsJson" to org.json.JSONArray().put(org.json.JSONObject(metadata as Map<*, *>)).toString(),
|
|
554
|
+
)))
|
|
555
|
+
} catch (e: Throwable) {
|
|
556
|
+
// Observability cannot change delivery or hide its original failure.
|
|
557
|
+
Log.w(TAG, "delivery diagnostic forwarding failed: ${e.javaClass.simpleName}")
|
|
510
558
|
}
|
|
511
559
|
}
|
|
512
560
|
|
|
@@ -542,6 +590,15 @@ class JSCRuntime private constructor(private val appContext: Context) {
|
|
|
542
590
|
|
|
543
591
|
// ------------------------------------------------------------------
|
|
544
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Evaluate for side effects. A completion value is never converted to
|
|
595
|
+
* Kotlin: quickjs-kt 1.0.15 hangs converting a Set, Map or array that
|
|
596
|
+
* contains itself, and miniapp code chooses its bundle's last value.
|
|
597
|
+
*/
|
|
598
|
+
private suspend fun QuickJs.evaluateForEffect(source: String, filename: String) {
|
|
599
|
+
evaluate<Any?>("$source\n;undefined", filename = filename)
|
|
600
|
+
}
|
|
601
|
+
|
|
545
602
|
private fun parseArgsEnvelope(argsJson: String): Pair<List<Any?>, String?> {
|
|
546
603
|
return try {
|
|
547
604
|
if (argsJson.startsWith("{")) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package com.mentra.crust.jsc
|
|
2
|
+
|
|
3
|
+
/** Metadata for one host PING. Never retains the envelope or exception message. */
|
|
4
|
+
internal class JSDeliveryDiagnostics(
|
|
5
|
+
private val deliveryId: Long,
|
|
6
|
+
private val contextId: Long?,
|
|
7
|
+
private val clockMs: () -> Long,
|
|
8
|
+
private val emit: (Map<String, Any?>) -> Unit,
|
|
9
|
+
) {
|
|
10
|
+
private val enteredAtMs = clockMs()
|
|
11
|
+
private var queuedAtMs: Long? = null
|
|
12
|
+
private var startedAtMs: Long? = null
|
|
13
|
+
|
|
14
|
+
fun entry() = report("native-entry")
|
|
15
|
+
|
|
16
|
+
fun queued() {
|
|
17
|
+
queuedAtMs = clockMs()
|
|
18
|
+
report("queued")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fun started() {
|
|
22
|
+
startedAtMs = clockMs()
|
|
23
|
+
report("started")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fun finished(error: Throwable? = null) = report(if (error == null) "finished" else "evaluate-failed", error)
|
|
27
|
+
|
|
28
|
+
fun dropped(reason: String, error: Throwable? = null) = report(reason, error)
|
|
29
|
+
|
|
30
|
+
private fun report(phase: String, error: Throwable? = null) {
|
|
31
|
+
val now = clockMs()
|
|
32
|
+
emit(mapOf(
|
|
33
|
+
"event" to "native-ping-delivery",
|
|
34
|
+
"deliveryId" to deliveryId,
|
|
35
|
+
"contextId" to contextId,
|
|
36
|
+
"phase" to phase,
|
|
37
|
+
"elapsedMs" to now - enteredAtMs,
|
|
38
|
+
"queueWaitMs" to queuedAtMs?.let { (startedAtMs ?: now) - it },
|
|
39
|
+
"evaluateMs" to startedAtMs?.let { now - it },
|
|
40
|
+
"errorClass" to error?.javaClass?.simpleName,
|
|
41
|
+
"causeClass" to error?.cause?.javaClass?.simpleName,
|
|
42
|
+
))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -461,6 +461,7 @@ object NavigationManager {
|
|
|
461
461
|
override fun onRoutesReady(routes: List<NavigationRoute>, routerOrigin: String) {
|
|
462
462
|
if (!sessionGuard.acceptsCallback(session)) return
|
|
463
463
|
if (routes.isEmpty()) {
|
|
464
|
+
stop()
|
|
464
465
|
callbacks.onError("no route found")
|
|
465
466
|
return
|
|
466
467
|
}
|
|
@@ -477,12 +478,15 @@ object NavigationManager {
|
|
|
477
478
|
if (!sessionGuard.acceptsCallback(session)) return
|
|
478
479
|
val msg = reasons.firstOrNull()?.message ?: "route request failed"
|
|
479
480
|
Log.e(TAG, "requestRoutes failed: $msg")
|
|
481
|
+
stop()
|
|
480
482
|
callbacks.onError(msg)
|
|
481
483
|
}
|
|
482
484
|
|
|
483
485
|
override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
|
|
484
486
|
if (!sessionGuard.acceptsCallback(session)) return
|
|
485
487
|
Log.w(TAG, "route request canceled")
|
|
488
|
+
stop()
|
|
489
|
+
callbacks.onError("route request canceled")
|
|
486
490
|
}
|
|
487
491
|
},
|
|
488
492
|
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package com.mentra.crust.preview
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.Rect
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import android.os.Handler
|
|
8
|
+
import android.os.Looper
|
|
9
|
+
import android.view.PixelCopy
|
|
10
|
+
import android.view.View
|
|
11
|
+
import java.io.File
|
|
12
|
+
import kotlin.coroutines.resumeWithException
|
|
13
|
+
import kotlinx.coroutines.Dispatchers
|
|
14
|
+
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
15
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
16
|
+
import kotlinx.coroutines.withContext
|
|
17
|
+
|
|
18
|
+
/** Copies the displayed miniapp pixels without invoking WebView's software renderer. */
|
|
19
|
+
object PixelCopyPreview {
|
|
20
|
+
@OptIn(ExperimentalCoroutinesApi::class)
|
|
21
|
+
suspend fun capture(activity: Activity, viewTag: Int): String =
|
|
22
|
+
withContext(Dispatchers.Main.immediate) {
|
|
23
|
+
check(!activity.isFinishing && !activity.isDestroyed) { "Screenshot activity is unavailable" }
|
|
24
|
+
val window = activity.window
|
|
25
|
+
val decor = window.decorView
|
|
26
|
+
val view = decor.findViewById<View>(viewTag)
|
|
27
|
+
?: error("Screenshot view $viewTag is unavailable")
|
|
28
|
+
check(view.isAttachedToWindow && view.isLaidOut && view.width > 0 && view.height > 0) {
|
|
29
|
+
"Screenshot view is not ready"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
val location = IntArray(2)
|
|
33
|
+
view.getLocationInWindow(location)
|
|
34
|
+
val bounds = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
|
|
35
|
+
check(Rect(0, 0, decor.width, decor.height).contains(bounds)) {
|
|
36
|
+
"Screenshot view must be fully inside the window"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
val bitmap = Bitmap.createBitmap(bounds.width(), bounds.height(), Bitmap.Config.ARGB_8888)
|
|
40
|
+
val captured = suspendCancellableCoroutine<Bitmap> { continuation ->
|
|
41
|
+
try {
|
|
42
|
+
PixelCopy.request(window, bounds, bitmap, { result ->
|
|
43
|
+
if (result == PixelCopy.SUCCESS) {
|
|
44
|
+
// A cancelled request still owns its bitmap until PixelCopy finishes.
|
|
45
|
+
continuation.resume(bitmap) { bitmap.recycle() }
|
|
46
|
+
} else {
|
|
47
|
+
bitmap.recycle()
|
|
48
|
+
continuation.resumeWithException(IllegalStateException("PixelCopy failed: $result"))
|
|
49
|
+
}
|
|
50
|
+
}, Handler(Looper.getMainLooper()))
|
|
51
|
+
} catch (error: Exception) {
|
|
52
|
+
bitmap.recycle()
|
|
53
|
+
continuation.resumeWithException(error)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
withContext(Dispatchers.IO) {
|
|
59
|
+
val file = File.createTempFile("miniapp-preview-", ".jpg", activity.cacheDir)
|
|
60
|
+
try {
|
|
61
|
+
file.outputStream().use { output ->
|
|
62
|
+
check(captured.compress(Bitmap.CompressFormat.JPEG, 50, output)) {
|
|
63
|
+
"Unable to encode miniapp screenshot"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
Uri.fromFile(file).toString()
|
|
67
|
+
} catch (error: Exception) {
|
|
68
|
+
file.delete()
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
captured.recycle()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package com.mentra.crust.http
|
|
2
|
+
|
|
3
|
+
import java.io.IOException
|
|
4
|
+
import java.io.InterruptedIOException
|
|
5
|
+
import java.net.ConnectException
|
|
6
|
+
import java.net.NoRouteToHostException
|
|
7
|
+
import java.net.SocketException
|
|
8
|
+
import java.net.SocketTimeoutException
|
|
9
|
+
import java.net.UnknownHostException
|
|
10
|
+
import javax.net.ssl.SSLHandshakeException
|
|
11
|
+
import org.junit.Assert.assertEquals
|
|
12
|
+
import org.junit.Test
|
|
13
|
+
|
|
14
|
+
class NativeHttpFailureTest {
|
|
15
|
+
@Test fun `preserves failure category without exception text`() {
|
|
16
|
+
val privateMessage = "https://private.example?token=secret SSID private-address"
|
|
17
|
+
val cases = listOf(
|
|
18
|
+
UnknownHostException(privateMessage) to "E_NATIVE_HTTP_DNS",
|
|
19
|
+
ConnectException(privateMessage) to "E_NATIVE_HTTP_CONNECT",
|
|
20
|
+
NoRouteToHostException(privateMessage) to "E_NATIVE_HTTP_CONNECT",
|
|
21
|
+
SocketTimeoutException(privateMessage) to "E_NATIVE_HTTP_TIMEOUT",
|
|
22
|
+
SSLHandshakeException(privateMessage) to "E_NATIVE_HTTP_TLS",
|
|
23
|
+
SocketException(privateMessage) to "E_NATIVE_HTTP_SOCKET",
|
|
24
|
+
InterruptedIOException(privateMessage) to "E_NATIVE_HTTP_INTERRUPTED",
|
|
25
|
+
IOException(privateMessage) to "E_NATIVE_HTTP_IO",
|
|
26
|
+
IllegalStateException(privateMessage) to "E_NATIVE_HTTP_OTHER",
|
|
27
|
+
)
|
|
28
|
+
cases.forEach { (error, expected) -> assertEquals(expected, nativeHttpFailureCode(error)) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test fun `retains a specific cause inside a generic native wrapper`() {
|
|
32
|
+
val wrapped = IOException("private wrapper", UnknownHostException("private hostname"))
|
|
33
|
+
assertEquals("E_NATIVE_HTTP_DNS", nativeHttpFailureCode(wrapped))
|
|
34
|
+
assertEquals("E_NATIVE_HTTP_IO", nativeHttpFailureCode(IllegalStateException(IOException("private"))))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test fun `never parses a category from private error text and bounds cyclic causes`() {
|
|
38
|
+
assertEquals("E_NATIVE_HTTP_OTHER", nativeHttpFailureCode(Exception("E_NATIVE_HTTP_DNS")))
|
|
39
|
+
val first = Exception("private one")
|
|
40
|
+
val second = Exception("private two", first)
|
|
41
|
+
first.initCause(second)
|
|
42
|
+
assertEquals("E_NATIVE_HTTP_OTHER", nativeHttpFailureCode(first))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
package com.mentra.crust.http
|
|
2
|
+
|
|
3
|
+
import java.io.IOException
|
|
4
|
+
import java.net.ServerSocket
|
|
5
|
+
import java.util.concurrent.CountDownLatch
|
|
6
|
+
import java.util.concurrent.TimeUnit
|
|
7
|
+
import okhttp3.Call
|
|
8
|
+
import okhttp3.Callback
|
|
9
|
+
import okhttp3.OkHttpClient
|
|
10
|
+
import okhttp3.Protocol
|
|
11
|
+
import okhttp3.Request
|
|
12
|
+
import okhttp3.Response
|
|
13
|
+
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
14
|
+
import okio.Timeout
|
|
15
|
+
import org.junit.Assert.*
|
|
16
|
+
import org.junit.Test
|
|
17
|
+
|
|
18
|
+
class NativeHttpRequestsTest {
|
|
19
|
+
private val request = Request.Builder().url("https://example.com/report").build()
|
|
20
|
+
|
|
21
|
+
private class PendingCall(private val request: Request) : Call {
|
|
22
|
+
var callback: Callback? = null
|
|
23
|
+
var cancelled = false
|
|
24
|
+
var onEnqueue: (() -> Unit)? = null
|
|
25
|
+
override fun request() = request
|
|
26
|
+
override fun execute(): Response = error("Test only exercises asynchronous calls")
|
|
27
|
+
override fun enqueue(responseCallback: Callback) {
|
|
28
|
+
callback = responseCallback
|
|
29
|
+
onEnqueue?.invoke()
|
|
30
|
+
}
|
|
31
|
+
override fun cancel() {cancelled = true}
|
|
32
|
+
override fun isExecuted() = callback != null
|
|
33
|
+
override fun isCanceled() = cancelled
|
|
34
|
+
override fun timeout() = Timeout.NONE
|
|
35
|
+
override fun clone(): Call = PendingCall(request)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test fun `register before enqueue and cancel only the requested call`() {
|
|
39
|
+
val calls = mutableListOf<PendingCall>()
|
|
40
|
+
val owner = NativeHttpRequests(Call.Factory { req -> PendingCall(req).also {calls.add(it)} })
|
|
41
|
+
owner.enqueue("one", request, {}, {throw it})
|
|
42
|
+
calls[0].onEnqueue = {assertEquals(2, owner.activeCount)}
|
|
43
|
+
owner.enqueue("two", request, {}, {throw it})
|
|
44
|
+
assertEquals(2, owner.activeCount)
|
|
45
|
+
owner.cancel("one")
|
|
46
|
+
assertTrue(calls[0].cancelled)
|
|
47
|
+
assertFalse(calls[1].cancelled)
|
|
48
|
+
assertEquals(1, owner.activeCount)
|
|
49
|
+
owner.cancel("not-owned")
|
|
50
|
+
assertEquals(1, owner.activeCount)
|
|
51
|
+
owner.cancelAll()
|
|
52
|
+
assertTrue(calls[1].cancelled)
|
|
53
|
+
assertEquals(0, owner.activeCount)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test fun `completion removes the active request and preserves the response`() {
|
|
57
|
+
val call = PendingCall(request)
|
|
58
|
+
val owner = NativeHttpRequests(Call.Factory {call})
|
|
59
|
+
var result: NativeHttpRequests.Result? = null
|
|
60
|
+
call.onEnqueue = {assertEquals(1, owner.activeCount)}
|
|
61
|
+
owner.enqueue("complete", request, {result = it}, {throw it})
|
|
62
|
+
call.callback!!.onResponse(call, Response.Builder().request(request).protocol(Protocol.HTTP_1_1)
|
|
63
|
+
.code(200).message("OK").header("Content-Type", "application/json").body("{}".toResponseBody()).build())
|
|
64
|
+
assertEquals("{}", result!!.body)
|
|
65
|
+
assertEquals("application/json", result!!.headers["content-type"])
|
|
66
|
+
assertEquals(0, owner.activeCount)
|
|
67
|
+
owner.cancel("complete")
|
|
68
|
+
assertFalse(call.cancelled)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test fun `network failure and synchronous enqueue failure leave no registry entries`() {
|
|
72
|
+
val call = PendingCall(request)
|
|
73
|
+
val owner = NativeHttpRequests(Call.Factory {call})
|
|
74
|
+
var failures = 0
|
|
75
|
+
owner.enqueue("failed", request, {}, {failures++})
|
|
76
|
+
call.callback!!.onFailure(call, IOException("offline"))
|
|
77
|
+
assertEquals(0, owner.activeCount)
|
|
78
|
+
call.onEnqueue = {throw IllegalStateException("dispatcher closed")}
|
|
79
|
+
owner.enqueue("enqueue-failed", request, {}, {failures++})
|
|
80
|
+
assertEquals(0, owner.activeCount)
|
|
81
|
+
assertEquals(2, failures)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test fun `duplicate IDs cannot replace existing ownership and destruction prevents later requests`() {
|
|
85
|
+
val calls = mutableListOf<PendingCall>()
|
|
86
|
+
val owner = NativeHttpRequests(Call.Factory {req -> PendingCall(req).also {calls.add(it)} })
|
|
87
|
+
var failures = 0
|
|
88
|
+
owner.enqueue("one", request, {}, {failures++})
|
|
89
|
+
owner.enqueue("one", request, {}, {failures++})
|
|
90
|
+
assertEquals(1, failures)
|
|
91
|
+
assertEquals(1, owner.activeCount)
|
|
92
|
+
assertNull(calls[1].callback)
|
|
93
|
+
owner.cancelAll()
|
|
94
|
+
assertTrue(calls[0].cancelled)
|
|
95
|
+
owner.enqueue("later", request, {}, {failures++})
|
|
96
|
+
assertEquals(2, failures)
|
|
97
|
+
assertEquals(2, calls.size)
|
|
98
|
+
assertEquals(0, owner.activeCount)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test fun `cancel interrupts an actual OkHttp response body and closes the server socket`() {
|
|
102
|
+
val client = OkHttpClient()
|
|
103
|
+
val owner = NativeHttpRequests(client)
|
|
104
|
+
val bodyStarted = CountDownLatch(1)
|
|
105
|
+
val socketClosed = CountDownLatch(1)
|
|
106
|
+
val failed = CountDownLatch(1)
|
|
107
|
+
ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress()).use {server ->
|
|
108
|
+
val thread = Thread {
|
|
109
|
+
server.accept().use {socket ->
|
|
110
|
+
socket.soTimeout = 5_000
|
|
111
|
+
val reader = socket.getInputStream().bufferedReader()
|
|
112
|
+
while (!reader.readLine().isNullOrEmpty()) {}
|
|
113
|
+
socket.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray())
|
|
114
|
+
socket.getOutputStream().flush()
|
|
115
|
+
bodyStarted.countDown()
|
|
116
|
+
if (socket.getInputStream().read() == -1) socketClosed.countDown()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
thread.isDaemon = true
|
|
120
|
+
thread.start()
|
|
121
|
+
val req = Request.Builder().url("http://127.0.0.1:${server.localPort}/report").build()
|
|
122
|
+
owner.enqueue("stalled-body", req, {fail("Cancelled body must not complete")}, {failed.countDown()})
|
|
123
|
+
assertTrue("server started body", bodyStarted.await(5, TimeUnit.SECONDS))
|
|
124
|
+
owner.cancel("stalled-body")
|
|
125
|
+
assertTrue("native request rejected", failed.await(5, TimeUnit.SECONDS))
|
|
126
|
+
assertTrue("socket closed", socketClosed.await(5, TimeUnit.SECONDS))
|
|
127
|
+
assertEquals(0, owner.activeCount)
|
|
128
|
+
thread.join(5_000)
|
|
129
|
+
}
|
|
130
|
+
client.dispatcher.executorService.shutdownNow()
|
|
131
|
+
client.connectionPool.evictAll()
|
|
132
|
+
}
|
|
133
|
+
}
|