also looking at this
fix(mobile): preserve native editing, navigation, and durable media requests
#369
36 files
+864
-185
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -24,7 +24,7 @@ class CraftNativeResumableDownloadTest { | ||
| 24 | 24 | private val body = "0123456789abcdefghij".toByteArray() |
| 25 | 25 | |
| 26 | 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, check: (String, MutableList<String?>) -> Unit) { | |
| 27 | private fun serve(ranges: Boolean = true, status: Int = 200, payload: ByteArray = body, check: (String, MutableList<String?>) -> Unit) { | |
| 28 | 28 | val seen = Collections.synchronizedList(mutableListOf<String?>()) |
| 29 | 29 | val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) |
| 30 | 30 | server.soTimeout = 5000 |
| @@ -45,16 +45,16 @@ class CraftNativeResumableDownloadTest { | ||
| 45 | 45 | val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt() |
| 46 | 46 | val code = when { |
| 47 | 47 | status != 200 -> status |
| 48 | ranges && start != null && start >= body.size -> 416 | |
| 48 | ranges && start != null && start >= payload.size -> 416 | |
| 49 | 49 | ranges && start != null -> 206 |
| 50 | 50 | else -> 200 |
| 51 | 51 | } |
| 52 | 52 | val bytes = when (code) { |
| 53 | 206 -> body.copyOfRange(start!!, body.size) | |
| 54 | 200 -> body | |
| 53 | 206 -> payload.copyOfRange(start!!, payload.size) | |
| 54 | 200 -> payload | |
| 55 | 55 | else -> byteArrayOf() |
| 56 | 56 | } |
| 57 | val contentRange = if (code == 206) "Content-Range: bytes $start-${body.size - 1}/${body.size}\r\n" else "" | |
| 57 | val contentRange = if (code == 206) "Content-Range: bytes $start-${payload.size - 1}/${payload.size}\r\n" else "" | |
| 58 | 58 | val headers = "HTTP/1.1 $code Test\r\nContent-Length: ${bytes.size}\r\n${contentRange}Connection: close\r\n\r\n" |
| 59 | 59 | socket.getOutputStream().write(headers.toByteArray(Charsets.US_ASCII) + bytes) |
| 60 | 60 | } |
| @@ -126,6 +126,14 @@ class CraftNativeResumableDownloadTest { | ||
| 126 | 126 | } |
| 127 | 127 | } |
| 128 | 128 | |
| 129 | @Test | |
| 130 | fun refusesAnEmptyResponseWithoutRetainingAnUnresumablePart() = part { file -> | |
| 131 | serve(payload = byteArrayOf()) { address, _ -> | |
| 132 | assertEquals(CraftResumableDownload.Fetched.Incomplete, CraftResumableDownload().fetch(address, emptyMap(), file) { false }) | |
| 133 | assertFalse("An empty file has no bytes to resume", file.exists()) | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 129 | 137 | @Test |
| 130 | 138 | fun keepsWhatItHasWhenStopped() = part { file -> |
| 131 | 139 | serve { address, _ -> |
| @@ -1475,8 +1475,8 @@ internal class CraftResumableDownload( | ||
| 1475 | 1475 | // HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size. |
| 1476 | 1476 | val encoded = connection.getHeaderField("Content-Encoding") != null |
| 1477 | 1477 | if (size <= 0 || (!encoded && total >= 0 && size != total)) { |
| 1478 | // Longer than the file says it is: nothing to resume from. | |
| 1479 | if (total in 0 until size) part.delete() | |
| 1478 | // Empty or longer than the file says: nothing useful to resume. | |
| 1479 | if (size <= 0 || total in 0 until size) part.delete() | |
| 1480 | 1480 | return Fetched.Incomplete |
| 1481 | 1481 | } |
| 1482 | 1482 | return Fetched.Complete(size) |
| @@ -23,6 +23,13 @@ class DownloadProbe : android.app.Instrumentation() { | ||
| 23 | 23 | check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled") |
| 24 | 24 | return ok |
| 25 | 25 | } |
| 26 | private fun awaitCached(address: String) { | |
| 27 | // The worker authors a 30-second first retry. Allow that backoff plus | |
| 28 | // the existing 25-second transfer budget, without issuing another keep. | |
| 29 | val until = System.currentTimeMillis() + 60000 | |
| 30 | while (CraftNativeMedia.local(targetContext, address) == null && System.currentTimeMillis() < until) Thread.sleep(25) | |
| 31 | check(CraftNativeMedia.local(targetContext, address) != null, "the durable worker recovers the complete file without another keep") | |
| 32 | } | |
| 26 | 33 | override fun onStart() { |
| 27 | 34 | val result = android.os.Bundle() |
| 28 | 35 | try { |
| @@ -80,10 +87,11 @@ class DownloadProbe : android.app.Instrumentation() { | ||
| 80 | 87 | val until = System.currentTimeMillis() + 25000 |
| 81 | 88 | while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25) |
| 82 | 89 | check(partial(), "download is in progress before process death") |
| 90 | check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video") | |
| 83 | 91 | android.os.Process.killProcess(android.os.Process.myPid()) |
| 84 | 92 | } |
| 85 | 93 | "recover" -> { |
| 86 | check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video") | |
| 94 | awaitCached(restart) | |
| 87 | 95 | check(keep(restart), "retry after process death downloads a complete video") |
| 88 | 96 | val media = android.media.MediaMetadataRetriever() |
| 89 | 97 | try { |
| @@ -98,7 +106,8 @@ class DownloadProbe : android.app.Instrumentation() { | ||
| 98 | 106 | check(!keep(broken), "truncated transfer fails") |
| 99 | 107 | check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache") |
| 100 | 108 | val root = CraftNativeMedia.file(targetContext, broken).parentFile!! |
| 101 | check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "failed transfer cleans staging files") | |
| 109 | check(CraftNativeMedia.part(targetContext, broken).length() > 0, "a truncated transfer retains bytes for resumption") | |
| 110 | awaitCached(broken) | |
| 102 | 111 | check(keep(broken), "same URL retries successfully") |
| 103 | 112 | check(!keep("$base/partial.mp4"), "unsolicited partial response is refused") |
| 104 | 113 | check(!keep("$base/empty.mp4"), "empty file is refused") |