craft-native 0.0.246 → 0.0.248

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 (46) hide show
  1. package/dist/android/src/index.d.ts +11 -0
  2. package/dist/android/src/index.js +33 -21
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +136 -1
  5. package/dist/android/templates/CraftHeartRate.kt.template +938 -0
  6. package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
  7. package/dist/android/templates/CraftNative.kt.template +17 -0
  8. package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
  11. package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
  12. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
  13. package/dist/android/templates/CraftNativeServices.kt.template +59 -1
  14. package/dist/android/templates/CraftNativeShell.kt.template +54 -14
  15. package/dist/android/templates/MainActivityNative.kt.template +369 -69
  16. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  17. package/dist/api/mobile.d.ts +39 -3
  18. package/dist/browser.js +9 -9
  19. package/dist/cli.js +90 -673
  20. package/dist/components/index.d.ts +50 -9
  21. package/dist/index.cjs +394 -438
  22. package/dist/index.d.cts +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +446 -493
  25. package/dist/ios/src/index.d.ts +2 -0
  26. package/dist/ios/src/index.js +54 -53
  27. package/dist/ios/templates/CraftApp.swift +37 -2
  28. package/dist/ios/templates/CraftHealth.swift +217 -0
  29. package/dist/ios/templates/CraftHeartRate.swift +6 -3
  30. package/dist/ios/templates/CraftHybrid.swift +18 -19
  31. package/dist/ios/templates/CraftLiveActivities.swift +1 -1
  32. package/dist/ios/templates/CraftNativeActions.swift +87 -8
  33. package/dist/ios/templates/CraftNativeFiles.swift +15 -3
  34. package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
  35. package/dist/ios/templates/CraftNativeMutation.swift +36 -0
  36. package/dist/ios/templates/CraftNativeScreen.swift +813 -65
  37. package/dist/ios/templates/CraftNativeServices.swift +4 -2
  38. package/dist/ios/templates/CraftNativeUI.swift +10 -2
  39. package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
  40. package/dist/ios/templates/testing/HostApp.swift +9 -2
  41. package/dist/macos/templates/CraftNativeMutation.swift +36 -0
  42. package/dist/macos/templates/native_layout.zig +226 -2
  43. package/dist/mobile-testing.js +21 -3
  44. package/dist/mobile.js +101 -63
  45. package/dist/testing/media-cache/media-cache.kt +4 -2
  46. package/package.json +1 -1
@@ -59,6 +59,7 @@ internal class CraftNativeServices(
59
59
  private val activity: () -> Activity?,
60
60
  ) {
61
61
  private val main = Handler(Looper.getMainLooper())
62
+ private val heartRateEnabled = config.optBoolean("enableHeartRate")
62
63
  private val health: CraftNativeHealth? = if (config.optBoolean("enableHealthConnect")) {
63
64
  CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory"))
64
65
  } else null
@@ -78,6 +79,14 @@ internal class CraftNativeServices(
78
79
  CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location).put("recordingId", id)))
79
80
  }
80
81
  }
82
+ if (heartRateEnabled) {
83
+ // The strap's readings and connection changes reach every screen
84
+ // and the app script as `craftHeartRate`, as on iOS. One sink for
85
+ // the process: each host sets the same one.
86
+ CraftHeartRate.get(context).sinks["native"] = { detail ->
87
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftHeartRate").put("detail", detail))
88
+ }
89
+ }
81
90
  }
82
91
 
83
92
  /** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */
@@ -104,6 +113,7 @@ internal class CraftNativeServices(
104
113
  // `health.grantedTypes()` and `health.openSettings()`.
105
114
  add("healthAvailability")
106
115
  }
116
+ if (config.optBoolean("enableWallet")) add("wallet")
107
117
  if (config.optBoolean("enableKeepAwake")) add("keepAwake")
108
118
  if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
109
119
  add("geolocation")
@@ -111,13 +121,17 @@ internal class CraftNativeServices(
111
121
  if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
112
122
  }
113
123
  if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
124
+ // A Bluetooth heart-rate strap (CraftHeartRate.kt), the app's own
125
+ // service: one connection and one recording for every screen.
126
+ if (heartRateEnabled) addAll(listOf("heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"))
114
127
  // craft.crashReporter: the app's crash and error reporter (bughq).
115
128
  if (CraftCrashReporter.enabled) add("crashReporter")
116
129
  }
117
130
 
118
131
  fun handles(module: String): Boolean = module in setOf(
119
132
  "Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
120
- "Location", "Media", "CrashReporter",
133
+ "Location", "Media", "CrashReporter", "HeartRate",
134
+ "Wallet",
121
135
  )
122
136
 
123
137
  fun perform(module: String, method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
@@ -132,11 +146,13 @@ internal class CraftNativeServices(
132
146
  "Share" -> share(args.optJSONObject(0) ?: JSONObject(), done)
133
147
  "Push" -> push(method, done)
134
148
  "Health" -> health?.perform(method, args, done) ?: done(failure("CAPABILITY_DISABLED", "Health Connect is not enabled for this app"))
149
+ "Wallet" -> wallet(method, args, done)
135
150
  "KeepAwake" -> keepAwake(args.optBoolean(0, true), done)
136
151
  "Brightness" -> brightness(method, args, done)
137
152
  "StatusBar" -> statusBar(args.optString(0, "default"), done)
138
153
  "Speech" -> speech(method, args, done)
139
154
  "Location" -> location(method, args, done)
155
+ "HeartRate" -> heartRate(method, args, done)
140
156
  "Media" -> when (method) {
141
157
  "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), args.optJSONObject(1)?.optBoolean("cellular", true) ?: true, done)
142
158
  "path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL))
@@ -163,6 +179,28 @@ internal class CraftNativeServices(
163
179
  }
164
180
  }
165
181
 
182
+ private fun wallet(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
183
+ if (!config.optBoolean("enableWallet")) return done(failure("CAPABILITY_DISABLED", "Wallet is disabled"))
184
+ when (method) {
185
+ "isAvailable" -> done(success(runCatching {
186
+ context.packageManager.getLaunchIntentForPackage("com.google.android.apps.walletnfcrel") != null
187
+ }.getOrDefault(false)))
188
+ "add" -> {
189
+ val source = args.optJSONObject(0) ?: return done(failure("INVALID_ARGUMENT", "Wallet.add needs a source object"))
190
+ val address = source.optString("url").trim()
191
+ if (!address.startsWith("https://")) return done(failure("INVALID_ARGUMENT", "Android Wallet.add needs an HTTPS save URL"))
192
+ val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to open Wallet"))
193
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(address))
194
+ if (intent.resolveActivity(host.packageManager) == null) return done(failure("UNAVAILABLE", "Google Wallet cannot open this save URL"))
195
+ runCatching { host.startActivity(intent) }
196
+ .onSuccess { done(success(true)) }
197
+ .onFailure { done(failure("PRESENTATION_FAILED", it.message ?: "Google Wallet could not open")) }
198
+ }
199
+ "has" -> done(failure("UNSUPPORTED", "Google Wallet pass lookup is not available through the save-link API"))
200
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Wallet method"))
201
+ }
202
+ }
203
+
166
204
  // MARK: Dialogs
167
205
 
168
206
  private fun dialog(method: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
@@ -363,6 +401,7 @@ internal class CraftNativeServices(
363
401
  }
364
402
 
365
403
  fun onRequestPermissionsResult(requestCode: Int): Boolean {
404
+ if (heartRateEnabled && CraftHeartRate.get(context).onPermissions(requestCode)) return true
366
405
  if (requestCode == LOCATION_PERMISSION_REQUEST) {
367
406
  val pending = pendingLocationPermission
368
407
  pendingLocationPermission = null
@@ -455,6 +494,25 @@ internal class CraftNativeServices(
455
494
  }
456
495
  }
457
496
 
497
+ // MARK: Heart rate (CraftHeartRate.kt)
498
+
499
+ /**
500
+ * A Bluetooth heart-rate strap, the same service as a page's
501
+ * `craft.heartRate`, in the iOS host's names (CraftNativeActions.swift's
502
+ * HeartRate module). Its events reach every screen as `craftHeartRate`.
503
+ */
504
+ private fun heartRate(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
505
+ if (!heartRateEnabled) return done(failure("CAPABILITY_DISABLED", "Heart rate is disabled"))
506
+ CraftHeartRate.get(context).perform(method, args.optJSONObject(0) ?: JSONObject(), activity()) { result ->
507
+ result.fold(
508
+ onSuccess = { done(success(it)) },
509
+ onFailure = { error ->
510
+ done(failure((error as? CraftHeartRateException)?.code ?: "CRAFT_ERROR", error.message ?: "Heart rate failed"))
511
+ },
512
+ )
513
+ }
514
+ }
515
+
458
516
  // MARK: Crash reporting (CraftCrashReporter.kt)
459
517
 
460
518
  private fun crashReporter(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
@@ -447,6 +447,24 @@ internal object CraftNativePrelude {
447
447
  // address the host through `window.craft`. Keep native screens
448
448
  // compatible without pretending a document exists.
449
449
  if (typeof globalThis.window === 'undefined') globalThis.window = globalThis;
450
+ // JavaScriptSandbox intentionally has no browser URL/encoding
451
+ // globals. Install named failures instead of letting a shared module
452
+ // die with an unhelpful `URLSearchParams is not defined` halfway
453
+ // through its load path. The full browser implementations belong to
454
+ // the shared STX runtime; these diagnostics keep the native boundary
455
+ // explicit until that runtime is present.
456
+ (function() {
457
+ function unavailable(name, hint) {
458
+ var failure = function() {
459
+ throw new ReferenceError(name + ' is not available on a native screen — ' + hint);
460
+ };
461
+ failure.prototype.constructor = failure;
462
+ return failure;
463
+ }
464
+ if (typeof globalThis.URL === 'undefined') globalThis.URL = unavailable('URL', 'use an absolute URL or build the path with encodeURIComponent');
465
+ if (typeof globalThis.URLSearchParams === 'undefined') globalThis.URLSearchParams = unavailable('URLSearchParams', 'build query strings with encodeURIComponent');
466
+ if (typeof globalThis.TextEncoder === 'undefined') globalThis.TextEncoder = unavailable('TextEncoder', 'use a native-supported encoding helper');
467
+ })();
450
468
  (function() {
451
469
  var forward = null;
452
470
  var listeners = [];
@@ -671,9 +689,11 @@ internal object CraftNativePrelude {
671
689
  // Older native bundles call the page-shaped `craft.dialog` API
672
690
  // directly. Keep that API on the capability bridge so JavaScript
673
691
  // Core/JavaScriptSandbox screens never fall back to a DOM sheet.
674
- if (Array.isArray(__stxNativeBridge.capabilities) && __stxNativeBridge.capabilities.indexOf('dialog') >= 0 && !craft.dialog) {
675
- var nextDialogRequest = 0;
676
- function dialogRequest(method, args) {
692
+ // Native capability ownership wins over any browser-shaped dialog
693
+ // object the bundle installed before the bridge ran. Otherwise an
694
+ // action sheet silently falls back to the DOM renderer.
695
+ var nextDialogRequest = 0;
696
+ function dialogRequest(method, args) {
677
697
  return new Promise(function(resolve, reject) {
678
698
  var id = 'craft-dialog-' + (++nextDialogRequest);
679
699
  var remove = __stxNativeBridge.addMessageListener(function(raw) {
@@ -702,13 +722,19 @@ internal object CraftNativePrelude {
702
722
  },
703
723
  }));
704
724
  });
725
+ }
726
+ function installNativeDialog() {
727
+ var craft = globalThis.craft = globalThis.craft || {};
728
+ if (Array.isArray(__stxNativeBridge.capabilities) && __stxNativeBridge.capabilities.indexOf('dialog') >= 0) {
729
+ craft.dialog = {
730
+ alert: function(options) { return dialogRequest('alert', [options || {}]).then(function() {}); },
731
+ confirm: function(options) { return dialogRequest('confirm', [options || {}]).then(Boolean); },
732
+ actionSheet: function(options) { return dialogRequest('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); },
733
+ };
705
734
  }
706
- craft.dialog = {
707
- alert: function(options) { return dialogRequest('alert', [options || {}]).then(function() {}); },
708
- confirm: function(options) { return dialogRequest('confirm', [options || {}]).then(Boolean); },
709
- actionSheet: function(options) { return dialogRequest('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); },
710
- };
711
735
  }
736
+ globalThis.__craftInstallNativeDialog = installNativeDialog;
737
+ installNativeDialog();
712
738
  })();
713
739
  """.trimIndent()
714
740
  }
@@ -1258,8 +1284,15 @@ internal object CraftNativeMedia {
1258
1284
  .addTag(TAG)
1259
1285
  .addTag("$TAG:${digest(address)}")
1260
1286
  .build()
1261
- // KEEP: a transfer already queued or running for this address is joined, not started again.
1262
- work.enqueueUniqueWork(workName(address, wifiOnly), androidx.work.ExistingWorkPolicy.KEEP, request)
1287
+ // A transfer running for this address is joined (KEEP), not started again. One
1288
+ // only queued, or waiting out a retry's backoff, is replaced: asked for again,
1289
+ // it runs now and resumes the part the last attempt left.
1290
+ val name = workName(address, wifiOnly)
1291
+ background.execute {
1292
+ val running = runCatching { work.getWorkInfosForUniqueWork(name).get() }.getOrDefault(emptyList())
1293
+ .any { it.state == androidx.work.WorkInfo.State.RUNNING }
1294
+ work.enqueueUniqueWork(name, if (running) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request)
1295
+ }
1263
1296
  val reason = when {
1264
1297
  !online(app) -> "offline"
1265
1298
  wifiOnly && metered(app) -> "cellular"
@@ -1298,8 +1331,13 @@ internal object CraftNativeMedia {
1298
1331
  val target = file(context, address)
1299
1332
  val staging = part(context, address)
1300
1333
  target.parentFile?.mkdirs()
1334
+ val download = CraftResumableDownload()
1335
+ val asked = headers.keys().asSequence().associateWith { headers.optString(it) }
1301
1336
  val fetched = try {
1302
- CraftResumableDownload().fetch(address, headers.keys().asSequence().associateWith { headers.optString(it) }, staging, stopped)
1337
+ // A part that no longer lines up was dropped: the whole file, now, not after a backoff.
1338
+ download.fetch(address, asked, staging, stopped).let { first ->
1339
+ if (first is CraftResumableDownload.Fetched.Restart) download.fetch(address, asked, staging, stopped) else first
1340
+ }
1303
1341
  } catch (error: Exception) {
1304
1342
  val full = generateSequence<Throwable>(error) { it.cause }.take(16).any {
1305
1343
  it is android.system.ErrnoException && it.errno == android.system.OsConstants.ENOSPC
@@ -1360,9 +1398,9 @@ internal object CraftNativeMedia {
1360
1398
  fun prune(context: Context, keep: List<String>): Int {
1361
1399
  val wanted = keep.map { digest(it) }.toSet()
1362
1400
  var removed = 0
1401
+ // A keep still waiting owns its address's files and its transfer until it is answered.
1402
+ val active = synchronized(lock) { waiting.keys.map { digest(it) }.toSet() }
1363
1403
  synchronized(lock) {
1364
- // A keep still waiting owns its address's files until it is answered.
1365
- val active = waiting.keys.map { digest(it) }.toSet()
1366
1404
  root(context).listFiles()?.forEach { file ->
1367
1405
  val owner = file.name.substringBefore('.')
1368
1406
  // Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
@@ -1375,7 +1413,7 @@ internal object CraftNativeMedia {
1375
1413
  runCatching {
1376
1414
  androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
1377
1415
  .filter { !it.state.isFinished }
1378
- .filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted } }
1416
+ .filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:").let { owner -> owner in wanted || owner in active } } }
1379
1417
  .forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) }
1380
1418
  }
1381
1419
  }
@@ -1435,6 +1473,8 @@ internal class CraftResumableDownload(
1435
1473
  if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
1436
1474
  try {
1437
1475
  val status = connection.responseCode
1476
+ // Part of a file nobody asked a part of: not something to keep.
1477
+ if (status == 206 && have == 0L) return Fetched.Refused(status)
1438
1478
  val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
1439
1479
  if (status == 416 || (status == 206 && !resumed)) {
1440
1480
  part.delete()