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+49-23
Changes to packages/android/compile-fixtures/CraftNativeResumableDownloadTest.kt
@@ -1,8 +1,12 @@
11package {{PACKAGE_NAME}}
22
3import com.sun.net.httpserver.HttpServer
43import java.io.File
5import java.net.InetSocketAddress
4import java.net.InetAddress
5import 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
@@ -21,30 +25,52 @@ class CraftNativeResumableDownloadTest {
2125
2226 /** Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records each request's Range. */
2327 private fun serve(ranges: Boolean = true, status: Int = 200, check: (String, MutableList<String?>) -> Unit) {
24 val seen = mutableListOf<String?>()
25 val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
26 server.createContext("/") { exchange ->
27 val range = exchange.requestHeaders.getFirst("Range")
28 seen.add(range)
29 val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt()
30 when {
31 status != 200 -> exchange.sendResponseHeaders(status, -1)
32 ranges && start != null && start >= body.size -> exchange.sendResponseHeaders(416, -1)
33 ranges && start != null -> {
34 exchange.responseHeaders.add("Content-Range", "bytes $start-${body.size - 1}/${body.size}")
35 exchange.sendResponseHeaders(206, (body.size - start).toLong())
36 exchange.responseBody.use { it.write(body, start, body.size - start) }
37 }
38 else -> {
39 exchange.sendResponseHeaders(200, body.size.toLong())
40 exchange.responseBody.use { it.write(body) }
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 }
44 seen.add(range)
45 val start = Regex("^bytes=(\\d+)-$").find(range.orEmpty())?.groupValues?.get(1)?.toInt()
46 val code = when {
47 status != 200 -> status
48 ranges && start != null && start >= body.size -> 416
49 ranges && start != null -> 206
50 else -> 200
51 }
52 val bytes = when (code) {
53 206 -> body.copyOfRange(start!!, body.size)
54 200 -> body
55 else -> byteArrayOf()
56 }
57 val contentRange = if (code == 206) "Content-Range: bytes $start-${body.size - 1}/${body.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)
4160 }
61 served.complete(Unit)
62 } catch (error: Throwable) {
63 served.completeExceptionally(error)
4264 }
43 exchange.close()
4465 }
45 server.start()
46 try { check("http://127.0.0.1:${server.address.port}/clip.mp4", seen) }
47 finally { server.stop(0) }
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 }
4874 }
4975
5076 private fun part(check: (File) -> Unit) {
packages/typescript/src/__tests__/mobile-media-testing.test.tsmodified+11-0
Changes to packages/typescript/src/__tests__/mobile-media-testing.test.ts
@@ -1,5 +1,16 @@
11import { expect, test } from 'bun:test'
22import { verifyMobileMediaCache } from '../mobile-testing'
3import { androidMediaProbeSource } from '../mobile-media-testing'
4
5test('the Android media probe includes the shipped resumable downloader and durable worker', async () => {
6 const shell = await Bun.file(new URL('../../../android/templates/CraftNativeShell.kt.template', import.meta.url)).text()
7 const source = androidMediaProbeSource(shell)
8 expect(source).toContain('internal object CraftNativeMedia {')
9 expect(source).toContain('internal class CraftResumableDownload(')
10 expect(source).toContain('internal class CraftNativeMediaWorker(')
11 expect(source).not.toContain('internal class CraftNativeChunks')
12 expect(() => androidMediaProbeSource(source.replace('internal class CraftNativeMediaWorker', 'class MissingWorker'))).toThrow('CraftNativeMediaWorker')
13})
314
415test('media cache tests reject an unknown platform before creating a probe', async () => {
516 await expect(verifyMobileMediaCache({ platform: 'desktop' as any })).rejects.toThrow('Choose ios or android')
packages/typescript/src/mobile-media-testing.tsmodified+13-4
Changes to packages/typescript/src/mobile-media-testing.ts
@@ -19,6 +19,15 @@ export interface MobileMediaCacheTestOptions {
1919 log?: (line: string) => void
2020}
2121
22/** Keep the probe on the complete shipped downloader, including its durable worker. */
23export function androidMediaProbeSource(shell: string): string {
24 return ['internal object CraftNativeMedia', 'internal class CraftResumableDownload', 'internal class CraftNativeMediaWorker'].map(declaration => {
25 const source = shell.match(new RegExp(`${declaration}\\b[\\s\\S]*?\\n\\}`))?.[0]
26 if (!source) throw new Error(`Android media implementation missing: ${declaration}`)
27 return source
28 }).join('\n\n')
29}
30
2231/** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */
2332export async function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{ platform: string, requests: Record<string, number> }> {
2433 if (options.platform !== 'ios' && options.platform !== 'android') throw new Error('Choose ios or android')
@@ -137,8 +146,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
137146 serial = options.device || ''
138147 if (!/^emulator-\d+$/.test(serial)) throw new Error('Select a disposable Android emulator with ANDROID_SERIAL')
139148 const shell = await Bun.file(join(root, 'android/templates/CraftNativeShell.kt.template')).text()
140 const media = shell.match(/internal object CraftNativeMedia \{[\s\S]*?\n\}/)?.[0]
141 if (!media) throw new Error('Android media implementation missing')
149 const media = androidMediaProbeSource(shell)
142150 const android = join(scratch, 'android')
143151 const app = join(android, 'app')
144152 const sources = join(app, 'src/main/java/dev/craft/mediaprobe')
@@ -155,9 +163,10 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption
155163 targetSdk = 35 };
156164 compileOptions { sourceCompatibility = JavaVersion.VERSION_17;
157165 targetCompatibility = JavaVersion.VERSION_17 };
158 kotlinOptions { jvmTarget = "17" } }`)
166 kotlinOptions { jvmTarget = "17" } }
167dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`)
159168 await Bun.write(join(app, 'src/main/AndroidManifest.xml'), `<manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.INTERNET"/><uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/><application android:usesCleartextTraffic="true" android:label="HQ Download Probe"/><instrumentation android:name=".DownloadProbe" android:targetPackage="${packageName}"/></manifest>`)
160 await Bun.write(join(sources, 'DownloadProbe.kt'), `package ${packageName}\nimport android.content.Context\nimport android.net.Uri\nimport android.os.Handler\nimport android.os.Looper\nimport org.json.JSONObject\nimport java.io.File\nimport java.util.concurrent.Executors\ninternal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)\n${media}\n${await Bun.file(join(fixtures, 'media-cache.kt')).text()}`)
169 await Bun.write(join(sources, 'DownloadProbe.kt'), `package ${packageName}\nimport android.content.Context\nimport android.content.SharedPreferences\nimport android.net.Uri\nimport android.os.Handler\nimport android.os.Looper\nimport org.json.JSONObject\nimport java.io.File\nimport java.util.concurrent.Executors\nimport java.util.concurrent.ConcurrentHashMap\ninternal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)\n${media}\n${await Bun.file(join(fixtures, 'media-cache.kt')).text()}`)
161170 await run([options.gradle || process.env.GRADLE_EXECUTABLE || 'gradle', ...(options.online ? [] : ['--offline']), '--no-daemon', ':app:assembleDebug'], android)
162171 print(await run([adb, '-s', serial, 'install', '-r', join(app, 'build/outputs/apk/debug/app-debug.apk')]))
163172 const base = `http://10.0.2.2:${server.port}/android`