ReviewOS

also looking at this

craft-native/craft

fix(mobile): preserve native editing, navigation, and durable media requests

#369
Merged chris wants to merge codex/native-vertical-scroll-tab-swipes into main
36 files +864 -185
packages/android/compile-fixtures/CraftNativeResumableDownloadTest.ktmodified+56-37
Changes to packages/android/compile-fixtures/CraftNativeResumableDownloadTest.kt
@@ -3,6 +3,10 @@ package
33import java.io.File
44import java.net.InetAddress
55import java.net.ServerSocket
6import java.util.Collections
7import java.util.concurrent.CompletableFuture
8import java.util.concurrent.TimeUnit
9import kotlin.concurrent.thread
610import java.nio.file.Files
711import org.junit.Assert.assertArrayEquals
812import org.junit.Assert.assertEquals
@@ -19,46 +23,54 @@ import org.junit.Test
1923class CraftNativeResumableDownloadTest {
2024 private val body = "0123456789abcdefghij".toByteArray()
2125
22 /**
23 * Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records
24 * each request's Range. A plain socket: Android's unit-test classpath has
25 * no com.sun.net.httpserver.
26 */
27 private fun serve(ranges: Boolean = true, status: Int = 200, check: (String, MutableList<String?>) -> Unit) {
28 val seen = java.util.Collections.synchronizedList(mutableListOf<String?>())
29 val server = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
30 val thread = Thread {
31 while (!server.isClosed) {
32 val socket = runCatching { server.accept() }.getOrNull() ?: break
33 socket.use { client ->
34 val reader = client.getInputStream().bufferedReader(Charsets.ISO_8859_1)
35 val lines = generateSequence { reader.readLine() }.takeWhile { it.isNotEmpty() }.toList()
36 val range = lines.drop(1).firstOrNull { it.startsWith("Range:", ignoreCase = true) }?.substringAfter(':')?.trim()
26 /** Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records each request's Range. */
27 private fun serve(ranges: Boolean = true, status: Int = 200, payload: ByteArray = body, check: (String, MutableList<String?>) -> Unit) {
28 val seen = Collections.synchronizedList(mutableListOf<String?>())
29 val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
30 server.soTimeout = 5000
31 val served = CompletableFuture<Unit>()
32 val worker = thread(name = "craft-media-http-fixture") {
33 try {
34 server.accept().use { socket ->
35 socket.soTimeout = 5000
36 val reader = socket.getInputStream().bufferedReader(Charsets.US_ASCII)
37 checkNotNull(reader.readLine()) { "Missing HTTP request" }
38 var range: String? = null
39 while (true) {
40 val header = reader.readLine() ?: break
41 if (header.isEmpty()) break
42 if (header.startsWith("Range:", ignoreCase = true)) range = header.substringAfter(':').trim()
43 }
3744 seen.add(range)
3845 val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt()
39 val out = client.getOutputStream()
40 fun head(code: Int, length: Int, extra: String = "") =
41 out.write("HTTP/1.1 $code X\r\nContent-Length: $length\r\n${extra}Connection: close\r\n\r\n".toByteArray(Charsets.ISO_8859_1))
42 when {
43 status != 200 -> head(status, 0)
44 ranges && start != null && start >= body.size -> head(416, 0)
45 ranges && start != null -> {
46 head(206, body.size - start, "Content-Range: bytes $start-${body.size - 1}/${body.size}\r\n")
47 out.write(body, start, body.size - start)
48 }
49 else -> {
50 head(200, body.size)
51 out.write(body)
52 }
46 val code = when {
47 status != 200 -> status
48 ranges && start != null && start >= payload.size -> 416
49 ranges && start != null -> 206
50 else -> 200
51 }
52 val bytes = when (code) {
53 206 -> payload.copyOfRange(start ?: 0, payload.size)
54 200 -> payload
55 else -> byteArrayOf()
5356 }
54 out.flush()
57 val contentRange = if (code == 206 && start != null) "Content-Range: bytes $start-${payload.size - 1}/${payload.size}\r\n" else ""
58 val headers = "HTTP/1.1 $code Test\r\nContent-Length: ${bytes.size}\r\n${contentRange}Connection: close\r\n\r\n"
59 socket.getOutputStream().write(headers.toByteArray(Charsets.US_ASCII) + bytes)
5560 }
61 served.complete(Unit)
62 } catch (error: Throwable) {
63 served.completeExceptionally(error)
5664 }
5765 }
58 thread.isDaemon = true
59 thread.start()
60 try { check("http://127.0.0.1:${server.localPort}/clip.mp4", seen) }
61 finally { server.close(); thread.join(2000) }
66 try {
67 check("http://127.0.0.1:${server.localPort}/clip.mp4", seen)
68 served.get(5, TimeUnit.SECONDS)
69 } finally {
70 server.close()
71 worker.join(5000)
72 assertFalse("The local HTTP fixture must stop after each transfer", worker.isAlive)
73 }
6274 }
6375
6476 private fun part(check: (File) -> Unit) {
@@ -115,14 +127,21 @@ class CraftNativeResumableDownloadTest {
115127 }
116128
117129 @Test
118 fun refusesAPartNobodyAskedFor() = part { file ->
119 serve(status = 206) { address, seen ->
130 fun refusesAnUnsolicitedPartialResponseWithoutRetryingIt() = part { file ->
131 serve(status = 206) { address, _ ->
120132 assertEquals(CraftResumableDownload.Fetched.Refused(206), CraftResumableDownload().fetch(address, emptyMap(), file) { false })
121 assertEquals(listOf<String?>(null), seen)
122133 assertFalse(file.exists())
123134 }
124135 }
125136
137 @Test
138 fun refusesAnEmptyResponseWithoutRetainingAnUnresumablePart() = part { file ->
139 serve(payload = byteArrayOf()) { address, _ ->
140 assertEquals(CraftResumableDownload.Fetched.Incomplete, CraftResumableDownload().fetch(address, emptyMap(), file) { false })
141 assertFalse("An empty file has no bytes to resume", file.exists())
142 }
143 }
144
126145 @Test
127146 fun keepsWhatItHasWhenStopped() = part { file ->
128147 serve { address, _ ->