craft-native 0.0.424 → 0.0.436

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.
@@ -478,7 +478,7 @@ internal class CraftNativeCapabilities(
478
478
  }
479
479
  "remove" -> {
480
480
  val file = ownedFile(args.optString(0)) ?: return failure("INVALID_ARGUMENT", "files.remove needs a file made by craft.files")
481
- success(file.delete())
481
+ success(removeOwnedFile(file))
482
482
  }
483
483
  "download" -> download(requestToken, args)
484
484
  else -> failure("UNKNOWN_ACTION", "Unsupported Files method")
@@ -802,7 +802,7 @@ internal class CraftNativeCapabilities(
802
802
  success(copied)
803
803
  } catch (error: Exception) {
804
804
  for (index in 0 until copied.length()) {
805
- copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.parentFile?.deleteRecursively() }
805
+ copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.let(::removeOwnedFile) }
806
806
  }
807
807
  val message = error.message ?: "The selected document could not be read"
808
808
  if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
@@ -915,6 +915,18 @@ internal class CraftNativeCapabilities(
915
915
  return file.takeIf { it.path.startsWith(root.path + File.separator) }
916
916
  }
917
917
 
918
+ /** Delete only a validated owned file; never sweep siblings or the shared root. */
919
+ private fun removeOwnedFile(file: File): Boolean {
920
+ // File.delete is non-recursive and reports permission failures. Reject
921
+ // directories before removal; parent pruning must remain best effort.
922
+ if (!runCatching { file.isFile && file.delete() }.getOrDefault(false)) return false
923
+ runCatching {
924
+ val parent = file.parentFile
925
+ if (parent != null && parent != fileRoot.canonicalFile && parent.isDirectory) parent.delete()
926
+ }
927
+ return true
928
+ }
929
+
918
930
  private fun safeFileName(raw: String): String {
919
931
  val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
920
932
  return name.ifBlank { "file" }.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
@@ -939,7 +951,7 @@ internal class CraftNativeCapabilities(
939
951
  val files = if (data is JSONArray) (0 until data.length()).mapNotNull { data.optJSONObject(it) }
940
952
  else listOfNotNull(data as? JSONObject)
941
953
  files.forEach { file ->
942
- ownedFile(file.optString("path"))?.parentFile?.deleteRecursively()
954
+ ownedFile(file.optString("path"))?.let(::removeOwnedFile)
943
955
  }
944
956
  }
945
957
 
@@ -57,13 +57,14 @@ internal class CraftNativeSounds(
57
57
  if (id.isBlank()) return done(failure("INVALID_ARGUMENT", "audio.play needs an id"))
58
58
  val uri = source(address)
59
59
  ?: return done(failure("INVALID_ARGUMENT", "audio.play needs an absolute http, https, file or content URL"))
60
- stop(id, 0.0)
61
-
62
60
  val requestedVolume = options.optDouble("volume", 1.0)
63
61
  val volume = requestedVolume.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0)?.toFloat()
64
62
  ?: return done(failure("INVALID_ARGUMENT", "audio.play volume must be a finite number between 0 and 1"))
65
63
  val fadeIn = options.optDouble("fadeIn", 0.0)
66
64
  if (!fadeIn.isFinite() || fadeIn < 0) return done(failure("INVALID_ARGUMENT", "audio.play fadeIn must be a non-negative finite number"))
65
+ // Reject malformed options before cancelling a sound with the same id.
66
+ // Its player, pending load and cancellation handle still belong to it.
67
+ stop(id, 0.0)
67
68
  val player = MediaPlayer()
68
69
  val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
69
70
  sounds[id] = sound
@@ -9,6 +9,10 @@ import android.content.pm.PackageManager
9
9
  import android.content.res.ColorStateList
10
10
  import android.content.res.Configuration
11
11
  import android.content.res.Resources
12
+ import android.hardware.Sensor
13
+ import android.hardware.SensorEvent
14
+ import android.hardware.SensorEventListener
15
+ import android.hardware.SensorManager
12
16
  import android.database.ContentObserver
13
17
  import android.content.Intent
14
18
  import android.app.PictureInPictureParams
@@ -4683,6 +4687,7 @@ class MainActivity : AppCompatActivity() {
4683
4687
  private val capabilityHandler = Handler(Looper.getMainLooper())
4684
4688
  private var hapticsEnabled = false
4685
4689
  private var clipboardEnabled = false
4690
+ private var shakeEnabled = false
4686
4691
  internal var nativeAppState = "inactive"
4687
4692
  private set
4688
4693
  private var deliveredInitialDeepLink = false
@@ -4755,6 +4760,7 @@ class MainActivity : AppCompatActivity() {
4755
4760
  }.getOrDefault(JSONObject())
4756
4761
  hapticsEnabled = config.optBoolean("enableHaptics", false)
4757
4762
  clipboardEnabled = config.optBoolean("enableClipboard", false)
4763
+ shakeEnabled = config.optBoolean("enableShakeGesture", false)
4758
4764
  // Normally started already by its content provider; this covers a
4759
4765
  // process that skipped providers (instrumentation).
4760
4766
  CraftCrashReporter.start(this)
@@ -6101,9 +6107,7 @@ class MainActivity : AppCompatActivity() {
6101
6107
  val handler = craftNativeEventName(events, "onPress", "onClick")
6102
6108
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
6103
6109
  setOnClickListener {
6104
- screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
6105
- val current = screen.handlers[this] ?: return@setOnClickListener
6106
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6110
+ dispatchNativePress(screen, it)
6107
6111
  }
6108
6112
  }
6109
6113
  "Button" -> (previous as? Button ?: CraftNativePointerButton(this).apply {
@@ -6877,9 +6881,7 @@ class MainActivity : AppCompatActivity() {
6877
6881
  control.isClickable = true
6878
6882
  if (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable")) {
6879
6883
  control.setOnClickListener {
6880
- screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
6881
- val current = screen.handlers[it] ?: return@setOnClickListener
6882
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6884
+ dispatchNativePress(screen, it)
6883
6885
  }
6884
6886
  control.setOnTouchListener { view, event ->
6885
6887
  if (!CraftNativePointers.ownsTouch(view) && event.actionMasked != MotionEvent.ACTION_CANCEL) return@setOnTouchListener false
@@ -6907,9 +6909,7 @@ class MainActivity : AppCompatActivity() {
6907
6909
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6908
6910
  }
6909
6911
  if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
6910
- screen.linkTargets[view]?.let { target -> navigateOpen(target); return@setOnTouchListener true }
6911
- val current = screen.handlers[view] ?: return@setOnTouchListener true
6912
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6912
+ dispatchNativePress(screen, view)
6913
6913
  }
6914
6914
  true
6915
6915
  }
@@ -6920,9 +6920,7 @@ class MainActivity : AppCompatActivity() {
6920
6920
  control.setOnTouchListener(null)
6921
6921
  if (screen.pressBaseBackgrounds.containsKey(control)) control.background = screen.pressBaseBackgrounds[control]
6922
6922
  control.setOnClickListener {
6923
- screen.linkTargets[control]?.let { target -> navigateOpen(target); return@setOnClickListener }
6924
- val current = screen.handlers[control] ?: return@setOnClickListener
6925
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6923
+ dispatchNativePress(screen, control)
6926
6924
  }
6927
6925
  }
6928
6926
  } else {
@@ -8254,7 +8252,45 @@ class MainActivity : AppCompatActivity() {
8254
8252
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
8255
8253
  }
8256
8254
 
8255
+ // MARK: - Shake gesture (enableShakeGesture)
8256
+
8257
+ /// The accelerometer, watched for a shake while the app is in front, as on
8258
+ /// iOS. Armed on resume and disarmed on pause, because the sensor is the
8259
+ /// whole cost of this feature and nobody can shake a phone at an app they
8260
+ /// cannot see.
8261
+ private var lastShakeAt = 0L
8262
+ private val shakeSensors by lazy { getSystemService(Context.SENSOR_SERVICE) as? SensorManager }
8263
+ private val shakeListener = object : SensorEventListener {
8264
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
8265
+ override fun onSensorChanged(event: SensorEvent?) {
8266
+ val reading = event?.values ?: return
8267
+ if (reading.size < 3) return
8268
+ // Android reports m/s^2 with gravity included, so a phone at rest
8269
+ // reads about 9.81. Converted to g to match the iOS threshold and
8270
+ // the force the event carries.
8271
+ val magnitude = kotlin.math.sqrt((reading[0] * reading[0] + reading[1] * reading[1] + reading[2] * reading[2]).toDouble()) / SensorManager.GRAVITY_EARTH
8272
+ if (magnitude < 2.2) return
8273
+ val now = System.currentTimeMillis()
8274
+ // One event per gesture, not one per sample.
8275
+ if (now - lastShakeAt < 2000L) return
8276
+ lastShakeAt = now
8277
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "shake").put("detail", JSONObject().put("force", magnitude)))
8278
+ }
8279
+ }
8280
+
8281
+ private fun armShakeGesture() {
8282
+ if (!shakeEnabled) return
8283
+ val sensor = shakeSensors?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return
8284
+ shakeSensors?.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_UI)
8285
+ }
8286
+
8287
+ private fun disarmShakeGesture() {
8288
+ if (!shakeEnabled) return
8289
+ shakeSensors?.unregisterListener(shakeListener)
8290
+ }
8291
+
8257
8292
  override fun onPause() {
8293
+ disarmShakeGesture()
8258
8294
  if (::services.isInitialized) services.pauseVoice()
8259
8295
  nativeAppState = "inactive"
8260
8296
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
@@ -8314,6 +8350,7 @@ class MainActivity : AppCompatActivity() {
8314
8350
 
8315
8351
  override fun onResume() {
8316
8352
  super.onResume()
8353
+ armShakeGesture()
8317
8354
  if (::services.isInitialized) services.resumed()
8318
8355
  }
8319
8356
 
@@ -8371,6 +8408,18 @@ class MainActivity : AppCompatActivity() {
8371
8408
  }
8372
8409
  }
8373
8410
 
8411
+ // The authored handler owns navigation. The bridge is asynchronous, so
8412
+ // opening the link as well cannot honor a handler's preventDefault intent.
8413
+ private fun dispatchNativePress(screen: Screen, view: View) {
8414
+ if (!view.isEnabled) return
8415
+ val handler = screen.handlers[view]
8416
+ if (handler != null) {
8417
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject()))
8418
+ } else {
8419
+ screen.linkTargets[view]?.let { target -> navigateOpen(target) }
8420
+ }
8421
+ }
8422
+
8374
8423
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
8375
8424
  if (!screen.active) return
8376
8425
  val message = JSONObject().put("type", type).put("payload", payload)
package/dist/browser.js CHANGED
@@ -317,7 +317,7 @@ async function createBrowserStorage(options) {
317
317
  };
318
318
  const accountChanged = async () => {
319
319
  const next = storage.getItem(tokenKey);
320
- if (next === token) {
320
+ if (next === token && (!transitioning || transition)) {
321
321
  await transition;
322
322
  return;
323
323
  }
@@ -351,7 +351,14 @@ async function createBrowserStorage(options) {
351
351
  if (before !== next)
352
352
  options.sessionChanged?.(next, before);
353
353
  })();
354
- await transition;
354
+ const attempt = transition;
355
+ try {
356
+ await attempt;
357
+ } catch (error) {
358
+ if (current === revision && transition === attempt)
359
+ transition = null;
360
+ throw error;
361
+ }
355
362
  };
356
363
  const secureKey = (name) => name === sessionTokenKey ? tokenKey : key(`credential:${name}`);
357
364
  return {
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.424";
3050
+ var version = "0.0.436";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.424";
378
+ var version = "0.0.436";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.424";
120
+ var version = "0.0.436";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -84,6 +84,8 @@ export interface CraftConfig {
84
84
  enableAudioRecording?: boolean;
85
85
  enableVideoRecording?: boolean;
86
86
  enableMotionSensors?: boolean;
87
+ /** Shaking the phone raises a `shake` event. Arms the accelerometer while the app is in front. */
88
+ enableShakeGesture?: boolean;
87
89
  enableLocalDatabase?: boolean;
88
90
  enableBluetooth?: boolean;
89
91
  enableNFC?: boolean;
@@ -18,7 +18,7 @@ function iosPermissions(config) {
18
18
  contacts: config.enableContacts,
19
19
  calendar: config.enableCalendar,
20
20
  bluetooth: config.enableBluetooth || config.enableHeartRate,
21
- motion: config.enableMotionSensors,
21
+ motion: config.enableMotionSensors || config.enableShakeGesture,
22
22
  biometric: config.enableBiometric,
23
23
  music: config.enableAppleMusic,
24
24
  health: config.enableHealthKit,
@@ -701,6 +701,7 @@ var DEFAULT_CONFIG = {
701
701
  enableAudioRecording: false,
702
702
  enableVideoRecording: false,
703
703
  enableMotionSensors: false,
704
+ enableShakeGesture: false,
704
705
  enableLocalDatabase: false,
705
706
  enableBluetooth: false,
706
707
  enableNFC: false,
@@ -1830,6 +1830,11 @@ struct CraftConfig: Codable {
1830
1830
  var enableAudioRecording: Bool = false
1831
1831
  var enableVideoRecording: Bool = false
1832
1832
  var enableMotionSensors: Bool = false
1833
+ /// Shaking the phone raises a `shake` event, for a report-a-problem
1834
+ /// gesture. Its own flag rather than a corner of `enableMotionSensors`,
1835
+ /// because it arms the accelerometer for as long as the app is in front:
1836
+ /// an app that wanted only the motion API should not pay for that.
1837
+ var enableShakeGesture: Bool = false
1833
1838
  var enableLocalDatabase: Bool = false
1834
1839
  var enableBluetooth: Bool = false
1835
1840
  var enableNFC: Bool = false
@@ -2413,6 +2418,10 @@ struct CraftWebView: UIViewRepresentable {
2413
2418
  private var motionManager: CMMotionManager?
2414
2419
  private var isMotionUpdating = false
2415
2420
 
2421
+ // Shake gesture (enableShakeGesture), armed only while the app is in front
2422
+ private var shakeMotion: CMMotionManager?
2423
+ private var lastShakeAt: Date?
2424
+
2416
2425
  // Bluetooth
2417
2426
  private var centralManager: CBCentralManager?
2418
2427
  private var peripheralManager: CBPeripheralManager?
@@ -2465,6 +2474,8 @@ struct CraftWebView: UIViewRepresentable {
2465
2474
  if config.enableMotionSensors {
2466
2475
  motionManager = CMMotionManager()
2467
2476
  }
2477
+ // The app starts in front, so the gesture starts armed.
2478
+ armShakeGesture()
2468
2479
  #endif
2469
2480
  if config.enableHealthKit {
2470
2481
  // Apple Health's heart rate as it arrives
@@ -3866,6 +3877,7 @@ struct CraftWebView: UIViewRepresentable {
3866
3877
  @objc private func appDidEnterBackground(_ notification: Notification) {
3867
3878
  backgroundedAt = Date()
3868
3879
  saveInteractionState()
3880
+ disarmShakeGesture()
3869
3881
  }
3870
3882
 
3871
3883
  @objc private func appDidReceiveMemoryWarning(_ notification: Notification) {
@@ -3997,6 +4009,7 @@ struct CraftWebView: UIViewRepresentable {
3997
4009
  }
3998
4010
 
3999
4011
  @objc private func appWillEnterForeground(_ notification: Notification) {
4012
+ armShakeGesture()
4000
4013
  if recoverOnForeground, let webView {
4001
4014
  // The page is about to be rebuilt; there is no one to resume.
4002
4015
  backgroundedAt = nil
@@ -8499,6 +8512,56 @@ struct CraftWebView: UIViewRepresentable {
8499
8512
  #endif
8500
8513
  }
8501
8514
 
8515
+ // MARK: - Shake Gesture
8516
+
8517
+ /// The accelerometer, watched for a shake while the app is in front.
8518
+ ///
8519
+ /// Deliberately not `startDeviceMotionUpdates`: that fuses attitude and
8520
+ /// gravity every sample, and a shake needs neither. Raw acceleration at
8521
+ /// 20 Hz is enough to see one and costs a fraction of the same watch.
8522
+ ///
8523
+ /// Armed on foreground and disarmed on background, because the sensor
8524
+ /// is the whole cost of this feature and nobody can shake a phone at an
8525
+ /// app they cannot see.
8526
+ private func armShakeGesture() {
8527
+ #if CRAFT_PERMISSION_MOTION
8528
+ guard config.enableShakeGesture, shakeMotion == nil else { return }
8529
+ let manager = CMMotionManager()
8530
+ guard manager.isAccelerometerAvailable else { return }
8531
+ manager.accelerometerUpdateInterval = 1.0 / 20.0
8532
+ shakeMotion = manager
8533
+ manager.startAccelerometerUpdates(to: .main) { [weak self] sample, _ in
8534
+ guard let self, let sample else { return }
8535
+ self.readShake(sample.acceleration)
8536
+ }
8537
+ #endif
8538
+ }
8539
+
8540
+ private func disarmShakeGesture() {
8541
+ #if CRAFT_PERMISSION_MOTION
8542
+ shakeMotion?.stopAccelerometerUpdates()
8543
+ shakeMotion = nil
8544
+ #endif
8545
+ }
8546
+
8547
+ #if CRAFT_PERMISSION_MOTION
8548
+ private func readShake(_ force: CMAcceleration) {
8549
+ // Total force, gravity included, so a phone at rest reads 1g and
8550
+ // the threshold is what a deliberate shake adds on top of it. At
8551
+ // 2.2g a pocket, a footfall and setting the phone down are all
8552
+ // below it, which is what a fitness app has to clear.
8553
+ let magnitude = sqrt(force.x * force.x + force.y * force.y + force.z * force.z)
8554
+ guard magnitude >= 2.2 else { return }
8555
+ let now = Date()
8556
+ // One event per gesture, not one per sample. A shake is a second
8557
+ // or two of samples over the threshold, and every one of them
8558
+ // would otherwise be reported as its own shake.
8559
+ if let lastShakeAt, now.timeIntervalSince(lastShakeAt) < 2.0 { return }
8560
+ lastShakeAt = now
8561
+ sendToWeb("shake", data: ["force": magnitude])
8562
+ }
8563
+ #endif
8564
+
8502
8565
  // MARK: - Motion Sensors
8503
8566
  private func startMotionUpdates(interval: Double, callbackId: String?) {
8504
8567
  #if CRAFT_PERMISSION_MOTION
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
18
18
  struct CraftNativeMutationResult {
19
19
  let batchId: String
20
20
  let revision: Int
21
- let document: [String: Any]?
22
21
  let updatedNodeIds: [String]
23
22
  let affectedNodeIds: [String]
24
23
  let requiresFullRender: Bool
24
+ private let materialize: () -> [String: Any]?
25
+ /// The committed tree, built when asked for: a host that redraws only the
26
+ /// nodes a batch touched never needs the whole tree.
27
+ var document: [String: Any]? { materialize() }
28
+
29
+ init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
30
+ self.batchId = batchId
31
+ self.revision = revision
32
+ self.materialize = document
33
+ self.updatedNodeIds = updatedNodeIds
34
+ self.affectedNodeIds = affectedNodeIds
35
+ self.requiresFullRender = requiresFullRender
36
+ }
25
37
  }
26
38
 
27
39
  /// Validates and applies versioned native-tree mutations without touching UIKit.
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
91
103
  var updatedNodeIds: [String] = []
92
104
  var affectedNodeIds: [String] = []
93
105
  var requiresFullRender = false
106
+ // Only an operation that adds, moves or removes nodes can leave one unreachable.
107
+ var reshaped = false
94
108
  for (index, value) in operations.enumerated() {
95
109
  guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
96
110
  throw CraftNativeMutationFailure(
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
99
113
  operationIndex: index
100
114
  )
101
115
  }
116
+ if name != "updateNode" { reshaped = true }
102
117
  do {
103
118
  switch name {
104
119
  case "createNode":
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
147
162
  guard let root = candidateNodes[candidateRoot], root.parent == nil else {
148
163
  throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
149
164
  }
150
- guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
165
+ guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
151
166
  throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
152
167
  }
153
168
  }
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
158
173
  return CraftNativeMutationResult(
159
174
  batchId: batchId,
160
175
  revision: nextRevision,
161
- document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
176
+ document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
162
177
  updatedNodeIds: updatedNodeIds,
163
178
  affectedNodeIds: affectedNodeIds,
164
179
  requiresFullRender: requiresFullRender