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.
| @@ -1,8 +1,12 @@ | ||
| 1 | 1 | package {{PACKAGE_NAME}} |
| 2 | 2 | |
| 3 | import com.sun.net.httpserver.HttpServer | |
| 4 | 3 | import java.io.File |
| 5 | import java.net.InetSocketAddress | |
| 4 | import java.net.InetAddress | |
| 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 |
| @@ -21,30 +25,52 @@ class CraftNativeResumableDownloadTest { | ||
| 21 | 25 | |
| 22 | 26 | /** Serves `body`, honouring `Range: bytes=N-` when `ranges` is on; records each request's Range. */ |
| 23 | 27 | 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) | |
| 41 | 60 | } |
| 61 | served.complete(Unit) | |
| 62 | } catch (error: Throwable) { | |
| 63 | served.completeExceptionally(error) | |
| 42 | 64 | } |
| 43 | exchange.close() | |
| 44 | 65 | } |
| 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 | } | |
| 48 | 74 | } |
| 49 | 75 | |
| 50 | 76 | private fun part(check: (File) -> Unit) { |
| @@ -1,5 +1,16 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | 2 | import { verifyMobileMediaCache } from '../mobile-testing' |
| 3 | import { androidMediaProbeSource } from '../mobile-media-testing' | |
| 4 | ||
| 5 | test('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 | }) | |
| 3 | 14 | |
| 4 | 15 | test('media cache tests reject an unknown platform before creating a probe', async () => { |
| 5 | 16 | await expect(verifyMobileMediaCache({ platform: 'desktop' as any })).rejects.toThrow('Choose ios or android') |
| @@ -19,6 +19,15 @@ export interface MobileMediaCacheTestOptions { | ||
| 19 | 19 | log?: (line: string) => void |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | /** Keep the probe on the complete shipped downloader, including its durable worker. */ | |
| 23 | export 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 | ||
| 22 | 31 | /** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */ |
| 23 | 32 | export async function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{ platform: string, requests: Record<string, number> }> { |
| 24 | 33 | if (options.platform !== 'ios' && options.platform !== 'android') throw new Error('Choose ios or android') |
| @@ -137,8 +146,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 137 | 146 | serial = options.device || '' |
| 138 | 147 | if (!/^emulator-\d+$/.test(serial)) throw new Error('Select a disposable Android emulator with ANDROID_SERIAL') |
| 139 | 148 | 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) | |
| 142 | 150 | const android = join(scratch, 'android') |
| 143 | 151 | const app = join(android, 'app') |
| 144 | 152 | const sources = join(app, 'src/main/java/dev/craft/mediaprobe') |
| @@ -155,9 +163,10 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 155 | 163 | targetSdk = 35 }; |
| 156 | 164 | compileOptions { sourceCompatibility = JavaVersion.VERSION_17; |
| 157 | 165 | targetCompatibility = JavaVersion.VERSION_17 }; |
| 158 | kotlinOptions { jvmTarget = "17" } }`) | |
| 166 | kotlinOptions { jvmTarget = "17" } } | |
| 167 | dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") }`) | |
| 159 | 168 | 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()}`) | |
| 161 | 170 | await run([options.gradle || process.env.GRADLE_EXECUTABLE || 'gradle', ...(options.online ? [] : ['--offline']), '--no-daemon', ':app:assembleDebug'], android) |
| 162 | 171 | print(await run([adb, '-s', serial, 'install', '-r', join(app, 'build/outputs/apk/debug/app-debug.apk')])) |
| 163 | 172 | const base = `http://10.0.2.2:${server.port}/android` |