also looking at this
fix(mobile): preserve native editing, navigation, and durable media requests
#369
36 files
+864
-185
| @@ -3,6 +3,10 @@ package | ||
| 3 | 3 | import java.io.File |
| 4 | 4 | import java.net.InetAddress |
| 5 | 5 | import java.net.ServerSocket |
| 6 | import java.util.Collections | |
| 7 | import java.util.concurrent.CompletableFuture | |
| 8 | import java.util.concurrent.TimeUnit | |
| 9 | import kotlin.concurrent.thread | |
| 6 | 10 | import java.nio.file.Files |
| 7 | 11 | import org.junit.Assert.assertArrayEquals |
| 8 | 12 | import org.junit.Assert.assertEquals |
| @@ -19,46 +23,54 @@ import org.junit.Test | ||
| 19 | 23 | class CraftNativeResumableDownloadTest { |
| 20 | 24 | private val body = "0123456789abcdefghij".toByteArray() |
| 21 | 25 | |
| 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 | } | |
| 37 | 44 | seen.add(range) |
| 38 | 45 | 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() | |
| 53 | 56 | } |
| 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) | |
| 55 | 60 | } |
| 61 | served.complete(Unit) | |
| 62 | } catch (error: Throwable) { | |
| 63 | served.completeExceptionally(error) | |
| 56 | 64 | } |
| 57 | 65 | } |
| 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 | } | |
| 62 | 74 | } |
| 63 | 75 | |
| 64 | 76 | private fun part(check: (File) -> Unit) { |
| @@ -115,14 +127,21 @@ class CraftNativeResumableDownloadTest { | ||
| 115 | 127 | } |
| 116 | 128 | |
| 117 | 129 | @Test |
| 118 | fun refusesAPartNobodyAskedFor() = part { file -> | |
| 119 | serve(status = 206) { address, seen -> | |
| 130 | fun refusesAnUnsolicitedPartialResponseWithoutRetryingIt() = part { file -> | |
| 131 | serve(status = 206) { address, _ -> | |
| 120 | 132 | assertEquals(CraftResumableDownload.Fetched.Refused(206), CraftResumableDownload().fetch(address, emptyMap(), file) { false }) |
| 121 | assertEquals(listOf<String?>(null), seen) | |
| 122 | 133 | assertFalse(file.exists()) |
| 123 | 134 | } |
| 124 | 135 | } |
| 125 | 136 | |
| 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 | ||
| 126 | 145 | @Test |
| 127 | 146 | fun keepsWhatItHasWhenStopped() = part { file -> |
| 128 | 147 | serve { address, _ -> |