@craft-native/android 0.0.424 → 0.0.435

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.
package/README.md CHANGED
@@ -344,6 +344,20 @@ when those features are unavailable. If neither bridge is supported, the app
344
344
  displays a clear unsupported-device error; it never silently opens a WebView.
345
345
  The native-navigation emulator fixture also relaunches the generated app and
346
346
  reads back storage and SQLite data through the compiled STX bundle.
347
+ Before its project or emulator is removed, the native-navigation runner retains
348
+ instrumentation reports and bounded diagnostics from the selected device in
349
+ `CRAFT_ANDROID_NATIVE_ARTIFACTS` (default `artifacts/android-native-navigation`).
350
+ `diagnostics.json` records each launched ADB client's parent-observed PID, exit
351
+ code and terminating signal, including clients killed before their own startup
352
+ logging runs. Diagnostic failures do not replace the instrumentation verdict;
353
+ an absent `ANDROID_SERIAL` prevents device queries entirely.
354
+ The [checkpoint interruption fixture](fixtures/checkpoint-interruption/README.md)
355
+ additionally tests public native snapshot promises across a controlled process
356
+ termination immediately before the real atomic file replacement. Its debug-only
357
+ preload leaves generated storage code unchanged and renders native controls.
358
+ The stock provider supplies only the script engine. This bounded fixture does
359
+ not establish the complete background/quota/cancellation matrix or power-loss
360
+ durability; original per-run receipts identify which device actually ran it.
347
361
  Support depends on the installed WebView provider, so validate on each device
348
362
  class you plan to ship. The [Android JavaScriptEngine guide](https://developer.android.com/develop/ui/views/layout/webapps/jsengine)
349
363
  documents the sandbox and device-support check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.424",
3
+ "version": "0.0.435",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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)