@craft-native/android 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.
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
|
@@ -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
|
|
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)?.
|
|
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"))?.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|