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

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.

packages/android/compile-fixtures/CraftNativeResumableDownloadTest.ktmodified+13-5
Changes to packages/android/compile-fixtures/CraftNativeResumableDownloadTest.kt
@@ -24,7 +24,7 @@ class CraftNativeResumableDownloadTest {
2424 private val body = "0123456789abcdefghij".toByteArray()
2525
2626 /** 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) {
2828 val seen = Collections.synchronizedList(mutableListOf<String?>())
2929 val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
3030 server.soTimeout = 5000
@@ -45,16 +45,16 @@ class CraftNativeResumableDownloadTest {
4545 val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt()
4646 val code = when {
4747 status != 200 -> status
48 ranges && start != null && start >= body.size -> 416
48 ranges && start != null && start >= payload.size -> 416
4949 ranges && start != null -> 206
5050 else -> 200
5151 }
5252 val bytes = when (code) {
53 206 -> body.copyOfRange(start!!, body.size)
54 200 -> body
53 206 -> payload.copyOfRange(start!!, payload.size)
54 200 -> payload
5555 else -> byteArrayOf()
5656 }
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 ""
5858 val headers = "HTTP/1.1 $code Test\r\nContent-Length: ${bytes.size}\r\n${contentRange}Connection: close\r\n\r\n"
5959 socket.getOutputStream().write(headers.toByteArray(Charsets.US_ASCII) + bytes)
6060 }
@@ -126,6 +126,14 @@ class CraftNativeResumableDownloadTest {
126126 }
127127 }
128128
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
129137 @Test
130138 fun keepsWhatItHasWhenStopped() = part { file ->
131139 serve { address, _ ->
packages/android/templates/CraftNativeShell.kt.templatemodified+2-2
Changes to packages/android/templates/CraftNativeShell.kt.template
@@ -1475,8 +1475,8 @@ internal class CraftResumableDownload(
14751475 // HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size.
14761476 val encoded = connection.getHeaderField("Content-Encoding") != null
14771477 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()
14801480 return Fetched.Incomplete
14811481 }
14821482 return Fetched.Complete(size)
packages/typescript/fixtures/media-cache/media-cache.ktmodified+11-2
Changes to packages/typescript/fixtures/media-cache/media-cache.kt
@@ -23,6 +23,13 @@ class DownloadProbe : android.app.Instrumentation() {
2323 check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled")
2424 return ok
2525 }
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 }
2633 override fun onStart() {
2734 val result = android.os.Bundle()
2835 try {
@@ -80,10 +87,11 @@ class DownloadProbe : android.app.Instrumentation() {
8087 val until = System.currentTimeMillis() + 25000
8188 while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
8289 check(partial(), "download is in progress before process death")
90 check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
8391 android.os.Process.killProcess(android.os.Process.myPid())
8492 }
8593 "recover" -> {
86 check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
94 awaitCached(restart)
8795 check(keep(restart), "retry after process death downloads a complete video")
8896 val media = android.media.MediaMetadataRetriever()
8997 try {
@@ -98,7 +106,8 @@ class DownloadProbe : android.app.Instrumentation() {
98106 check(!keep(broken), "truncated transfer fails")
99107 check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
100108 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)
102111 check(keep(broken), "same URL retries successfully")
103112 check(!keep("$base/partial.mp4"), "unsolicited partial response is refused")
104113 check(!keep("$base/empty.mp4"), "empty file is refused")