craft-native 0.0.251 → 0.0.262

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/android/src/index.d.ts +4 -2
  2. package/dist/android/src/index.js +69 -5
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  5. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  6. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  7. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  8. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +0 -1
  10. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  11. package/dist/android/templates/CraftNativeShell.kt.template +73 -23
  12. package/dist/android/templates/MainActivityNative.kt.template +217 -28
  13. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  14. package/dist/api/mobile.d.ts +9 -1
  15. package/dist/cli.js +1 -1
  16. package/dist/components/index.d.ts +19 -10
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/templates/CraftApp.swift +36 -4
  20. package/dist/ios/templates/CraftHealth.swift +38 -0
  21. package/dist/ios/templates/CraftHybrid.swift +38 -8
  22. package/dist/ios/templates/CraftNativeActions.swift +1 -1
  23. package/dist/ios/templates/CraftNativeFlatList.swift +34 -20
  24. package/dist/ios/templates/CraftNativeScreen.swift +308 -28
  25. package/dist/ios/templates/testing/DriverTests.swift.template +21 -1
  26. package/dist/mobile-media-testing.d.ts +2 -0
  27. package/dist/mobile-testing.js +19 -10
  28. package/dist/mobile.js +1 -1
  29. package/dist/testing/media-cache/media-cache.kt +47 -6
  30. package/package.json +1 -1
@@ -37,6 +37,16 @@ ${stderr}`.trim() };
37
37
  }
38
38
 
39
39
  // src/mobile-media-testing.ts
40
+ function androidMediaProbeSource(shell) {
41
+ return ["internal object CraftNativeMedia", "internal class CraftResumableDownload", "internal class CraftNativeMediaWorker"].map((declaration) => {
42
+ const source = shell.match(new RegExp(`${declaration}\\b[\\s\\S]*?\\n\\}`))?.[0];
43
+ if (!source)
44
+ throw new Error(`Android media implementation missing: ${declaration}`);
45
+ return source;
46
+ }).join(`
47
+
48
+ `);
49
+ }
40
50
  async function verifyMobileMediaCache(options) {
41
51
  if (options.platform !== "ios" && options.platform !== "android")
42
52
  throw new Error("Choose ios or android");
@@ -129,7 +139,7 @@ Connection: close\r
129
139
  }
130
140
  const empty = path.endsWith("/empty.mp4");
131
141
  const partial = path.endsWith("/partial.mp4");
132
- const cut = (path.endsWith("/broken.mp4") || path.endsWith("/restart.mp4")) && n === 1;
142
+ const cut = (path.endsWith("/broken.mp4") || path.endsWith("/retry.mp4") || path.endsWith("/restart.mp4")) && n === 1;
133
143
  const slow = path.endsWith("/slow.mp4") || path.endsWith("/restart.mp4") && n === 1;
134
144
  socket.write(`HTTP/1.1 ${partial ? "206 Partial Content" : "200 OK"}\r
135
145
  Content-Type: video/mp4\r
@@ -250,12 +260,7 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
250
260
  if (!/^emulator-\d+$/.test(serial))
251
261
  throw new Error("Select a disposable Android emulator with ANDROID_SERIAL");
252
262
  const shell = await Bun.file(join(root, "android/templates/CraftNativeShell.kt.template")).text();
253
- const pieces = ["internal object CraftNativeMedia {", "internal class CraftResumableDownload(", "internal class CraftNativeMediaWorker("].map((start) => shell.match(new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?\\n\\}`))?.[0]);
254
- if (pieces.some((piece) => !piece))
255
- throw new Error("Android media implementation missing");
256
- const media = pieces.join(`
257
-
258
- `);
263
+ const media = androidMediaProbeSource(shell);
259
264
  const android = join(scratch, "android");
260
265
  const app = join(android, "app");
261
266
  const sources = join(app, "src/main/java/dev/craft/mediaprobe");
@@ -267,9 +272,9 @@ include(":app")`);
267
272
  await Bun.write(join(android, "build.gradle.kts"), 'plugins { id("com.android.application") version "8.9.1" apply false; id("org.jetbrains.kotlin.android") version "2.0.21" apply false }');
268
273
  await Bun.write(join(android, "local.properties"), `sdk.dir=${sdk}
269
274
  `);
270
- await Bun.write(join(android, "gradle.properties"), `org.gradle.jvmargs=-Xmx768m
275
+ await Bun.write(join(android, "gradle.properties"), `android.useAndroidX=true
276
+ org.gradle.jvmargs=-Xmx768m
271
277
  org.gradle.workers.max=2
272
- android.useAndroidX=true
273
278
  `);
274
279
  await Bun.write(join(app, "build.gradle.kts"), `plugins { id("com.android.application");
275
280
  id("org.jetbrains.kotlin.android") }
@@ -291,8 +296,8 @@ import android.os.Handler
291
296
  import android.os.Looper
292
297
  import org.json.JSONObject
293
298
  import java.io.File
294
- import java.util.concurrent.ConcurrentHashMap
295
299
  import java.util.concurrent.Executors
300
+ import java.util.concurrent.ConcurrentHashMap
296
301
  internal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)
297
302
  ${media}
298
303
  ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
@@ -327,6 +332,10 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
327
332
  if (counts.get("/android/restart.mp4") !== 1)
328
333
  throw new Error("Android process did not start interrupted download");
329
334
  await probe("recover");
335
+ if (counts.get("/android/retry.mp4") !== 2)
336
+ throw new Error("Android explicit retry did not fetch once more immediately");
337
+ if (counts.get("/android/settled.mp4") !== 1)
338
+ throw new Error("Android explicit keep did not replace the settled running attempt exactly once");
330
339
  if (!resumed.has("/android/restart.mp4"))
331
340
  throw new Error("Android did not resume the interrupted download from its part file");
332
341
  if (counts.get("/android/slow.mp4") !== 1)
package/dist/mobile.js CHANGED
@@ -1725,7 +1725,7 @@ var health = {
1725
1725
  return () => {};
1726
1726
  const listener = (event) => {
1727
1727
  const detail = event.detail;
1728
- if (detail && typeof detail.id === "string" && (detail.event === "state" || detail.event === "error"))
1728
+ if (detail && typeof detail.id === "string" && (detail.event === "state" || detail.event === "metrics" || detail.event === "error"))
1729
1729
  callback(detail);
1730
1730
  };
1731
1731
  window.addEventListener("craftHealthWorkout", listener);
@@ -1,3 +1,14 @@
1
+ /** Holds a completed attempt in RUNNING, before WorkManager commits its result. */
2
+ class SettledMediaAttemptWorker(context: Context, parameters: androidx.work.WorkerParameters) : androidx.work.Worker(context, parameters) {
3
+ private val released = java.util.concurrent.CountDownLatch(1)
4
+ override fun doWork(): Result {
5
+ setProgressAsync(androidx.work.workDataOf("craft.media.attemptSettled" to true)).get()
6
+ released.await(30, java.util.concurrent.TimeUnit.SECONDS)
7
+ return Result.retry()
8
+ }
9
+ override fun onStopped() { released.countDown() }
10
+ }
11
+
1
12
  class DownloadProbe : android.app.Instrumentation() {
2
13
  override fun onCreate(arguments: android.os.Bundle?) {
3
14
  super.onCreate(arguments)
@@ -23,6 +34,13 @@ class DownloadProbe : android.app.Instrumentation() {
23
34
  check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled")
24
35
  return ok
25
36
  }
37
+ private fun awaitCached(address: String) {
38
+ // The worker authors a 30-second first retry. Allow that backoff plus
39
+ // the existing 25-second transfer budget, without issuing another keep.
40
+ val until = System.currentTimeMillis() + 60000
41
+ while (CraftNativeMedia.local(targetContext, address) == null && System.currentTimeMillis() < until) Thread.sleep(25)
42
+ check(CraftNativeMedia.local(targetContext, address) != null, "the durable worker recovers the complete file without another keep")
43
+ }
26
44
  override fun onStart() {
27
45
  val result = android.os.Bundle()
28
46
  try {
@@ -80,10 +98,11 @@ class DownloadProbe : android.app.Instrumentation() {
80
98
  val until = System.currentTimeMillis() + 25000
81
99
  while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
82
100
  check(partial(), "download is in progress before process death")
101
+ check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
83
102
  android.os.Process.killProcess(android.os.Process.myPid())
84
103
  }
85
104
  "recover" -> {
86
- check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
105
+ awaitCached(restart)
87
106
  check(keep(restart), "retry after process death downloads a complete video")
88
107
  val media = android.media.MediaMetadataRetriever()
89
108
  try {
@@ -98,12 +117,34 @@ class DownloadProbe : android.app.Instrumentation() {
98
117
  check(!keep(broken), "truncated transfer fails")
99
118
  check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
100
119
  val root = CraftNativeMedia.file(targetContext, broken).parentFile!!
101
- val brokenTarget = CraftNativeMedia.file(targetContext, broken)
102
- check(root.listFiles()?.none { it.name.endsWith(".part") && it.name != "${brokenTarget.name}.part" } != false, "a failed transfer leaves only its own part, to resume")
103
- check(keep(broken), "same URL retries successfully, at once")
104
- check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "a finished transfer leaves no staging files")
105
- val queued = "$base/queued.mp4"
120
+ check(CraftNativeMedia.part(targetContext, broken).length() > 0, "a truncated transfer retains bytes for resumption")
121
+ awaitCached(broken)
122
+ check(keep(broken), "same URL retries successfully")
123
+ val retry = "$base/retry.mp4"
124
+ check(!keep(retry), "a separate interrupted transfer reports its failure")
125
+ check(keep(retry), "an explicit retry runs immediately, without waiting for backoff")
126
+ check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "finished retries leave no staging files")
106
127
  val work = androidx.work.WorkManager.getInstance(targetContext)
128
+ val settled = "$base/settled.mp4"
129
+ val settledName = "craft-media:${CraftNativeMedia.digest(settled)}:any"
130
+ val settledRequest = androidx.work.OneTimeWorkRequestBuilder<SettledMediaAttemptWorker>()
131
+ .addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(settled)}")
132
+ .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
133
+ work.enqueueUniqueWork(settledName, androidx.work.ExistingWorkPolicy.KEEP, settledRequest).result.get()
134
+ val settledDeadline = System.currentTimeMillis() + 10000
135
+ var settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
136
+ while ((settledInfo.state != androidx.work.WorkInfo.State.RUNNING || !settledInfo.progress.getBoolean("craft.media.attemptSettled", false)) && System.currentTimeMillis() < settledDeadline) {
137
+ Thread.sleep(25)
138
+ settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
139
+ }
140
+ check(settledInfo.state == androidx.work.WorkInfo.State.RUNNING && settledInfo.progress.getBoolean("craft.media.attemptSettled", false), "the settled attempt still has WorkManager's running identity")
141
+ check(keep(settled), "an explicit keep replaces a settled running attempt immediately")
142
+ // REPLACE can prune the old WorkSpec. Inspect the current
143
+ // unique chain rather than requiring the removed row.
144
+ val replacement = work.getWorkInfosForUniqueWork(settledName).get()
145
+ check(replacement.size == 1 && replacement.single().id != settledRequest.id && replacement.single().state != androidx.work.WorkInfo.State.CANCELLED, "the settled work identity was replaced, not joined")
146
+
147
+ val queued = "$base/queued.mp4"
107
148
  val queuedName = "craft-media:${CraftNativeMedia.digest(queued)}:any"
108
149
  val queuedRequest = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
109
150
  .setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.251",
3
+ "version": "0.0.262",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",