craft-native 0.0.176 → 0.0.178
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/dist/android/src/index.d.ts +1 -0
- package/dist/android/src/index.js +11 -1
- package/dist/android/templates/CraftBluetooth.kt.template +211 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +8 -0
- package/dist/android/templates/MainActivityNative.kt.template +15 -11
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/src/testing.d.ts +5 -0
- package/dist/ios/templates/CraftApp.swift +19 -0
- package/dist/ios/templates/CraftBluetooth.swift +243 -0
- package/dist/ios/templates/CraftNativeActions.swift +10 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +17 -7
- package/dist/ios/templates/CraftNativeScreen.swift +166 -31
- package/dist/ios/templates/testing/DriverTests.swift.template +8 -0
- package/package.json +1 -1
|
@@ -25,6 +25,7 @@ export interface CraftAndroidConfig {
|
|
|
25
25
|
enableSecureStorage?: boolean;
|
|
26
26
|
enableGeolocation?: boolean;
|
|
27
27
|
enableBackgroundLocation?: boolean;
|
|
28
|
+
enableBluetooth?: boolean;
|
|
28
29
|
enableHealthConnect?: boolean;
|
|
29
30
|
/**
|
|
30
31
|
* The Health Connect data the app reads and writes, by the shared type
|
|
@@ -183,6 +183,7 @@ var DEFAULT_CONFIG = {
|
|
|
183
183
|
enableSecureStorage: false,
|
|
184
184
|
enableGeolocation: false,
|
|
185
185
|
enableBackgroundLocation: false,
|
|
186
|
+
enableBluetooth: false,
|
|
186
187
|
enableHealthConnect: false,
|
|
187
188
|
enableKeepAwake: false,
|
|
188
189
|
enableDeepLinks: false,
|
|
@@ -371,7 +372,15 @@ function renderAndroidPermissions(config) {
|
|
|
371
372
|
healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
|
|
372
373
|
if (config.renderer === "native" && config.backgroundRefresh?.enabled)
|
|
373
374
|
permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
|
|
374
|
-
|
|
375
|
+
const bluetooth = config.enableBluetooth ? [
|
|
376
|
+
' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
|
|
377
|
+
' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
|
|
378
|
+
' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
|
|
379
|
+
' <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />',
|
|
380
|
+
...config.enableGeolocation ? [] : [' <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />']
|
|
381
|
+
].join(`
|
|
382
|
+
`) : "";
|
|
383
|
+
return [bluetooth, ...[...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`)].filter(Boolean).join(`
|
|
375
384
|
`);
|
|
376
385
|
}
|
|
377
386
|
function writeNativeShellSources(output, packagePath, packageName, config) {
|
|
@@ -380,6 +389,7 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
|
|
|
380
389
|
const source = readFileSync(join(TEMPLATES_DIR, template), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
|
|
381
390
|
writeFileSync(join(javaDir, file), replace(source));
|
|
382
391
|
};
|
|
392
|
+
write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
|
|
383
393
|
write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
|
|
384
394
|
write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
|
|
385
395
|
write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.bluetooth.*
|
|
6
|
+
import android.bluetooth.le.*
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.content.pm.PackageManager
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.os.Handler
|
|
11
|
+
import android.os.Looper
|
|
12
|
+
import android.os.ParcelUuid
|
|
13
|
+
import androidx.core.app.ActivityCompat
|
|
14
|
+
import androidx.core.content.ContextCompat
|
|
15
|
+
import org.json.JSONArray
|
|
16
|
+
import org.json.JSONObject
|
|
17
|
+
import java.util.UUID
|
|
18
|
+
|
|
19
|
+
/** A selected GATT channel; one request/notification exchange at a time. */
|
|
20
|
+
@Suppress("MissingPermission", "DEPRECATION")
|
|
21
|
+
internal class CraftBluetooth(private val context: Context) {
|
|
22
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
23
|
+
private val adapter get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
|
24
|
+
private val devices = linkedMapOf<String, BluetoothDevice>()
|
|
25
|
+
private val rows = linkedMapOf<String, JSONObject>()
|
|
26
|
+
private var scanDone: ((CraftNativeCapabilityResult) -> Unit)? = null
|
|
27
|
+
private var permissionScan: JSONObject? = null
|
|
28
|
+
private var prefix = ""
|
|
29
|
+
private var gatt: BluetoothGatt? = null
|
|
30
|
+
private var writer: BluetoothGattCharacteristic? = null
|
|
31
|
+
private var notifier: BluetoothGattCharacteristic? = null
|
|
32
|
+
private var serviceId: UUID? = null
|
|
33
|
+
private var writeId: UUID? = null
|
|
34
|
+
private var notifyId: UUID? = null
|
|
35
|
+
private var connected = false
|
|
36
|
+
private var mtu = 23
|
|
37
|
+
private var connectDone: ((CraftNativeCapabilityResult) -> Unit)? = null
|
|
38
|
+
private var exchangeDone: ((CraftNativeCapabilityResult) -> Unit)? = null
|
|
39
|
+
private var responsePrefix = byteArrayOf()
|
|
40
|
+
private var scanTimer: Runnable? = null
|
|
41
|
+
private var connectTimer: Runnable? = null
|
|
42
|
+
private var exchangeTimer: Runnable? = null
|
|
43
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
44
|
+
private fun timer(ms: Long, action: () -> Unit) = Runnable(action).also { handler.postDelayed(it, ms) }
|
|
45
|
+
private fun permitted() = permissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
|
|
46
|
+
private fun permissions() = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
|
47
|
+
|
|
48
|
+
fun perform(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
49
|
+
handler.post {
|
|
50
|
+
val options = args.optJSONObject(0) ?: JSONObject()
|
|
51
|
+
try {
|
|
52
|
+
when (method) {
|
|
53
|
+
"getState" -> done(CraftNativeCapabilityResult(JSONObject().put("available", adapter != null).put("connected", connected).put("scanning", scanDone != null)))
|
|
54
|
+
"scan" -> scan(options, done)
|
|
55
|
+
"stopScan" -> { stopScan(failure("CANCELLED", "Bluetooth scan cancelled")); done(CraftNativeCapabilityResult(true)) }
|
|
56
|
+
"connect" -> connect(options, done)
|
|
57
|
+
"exchange" -> exchange(options, done)
|
|
58
|
+
"disconnect" -> { disconnect(); done(CraftNativeCapabilityResult(true)) }
|
|
59
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported Bluetooth method"))
|
|
60
|
+
}
|
|
61
|
+
} catch (_: SecurityException) { disconnect(); done(failure("NOT_AUTHORIZED", "Bluetooth permission is required")) }
|
|
62
|
+
catch (_: IllegalArgumentException) { done(failure("INVALID_ARGUMENT", "Invalid Bluetooth channel options")) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fun scan(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
67
|
+
if (scanDone != null || connectDone != null || exchangeDone != null) { done(failure("BUSY", "A Bluetooth operation is running")); return }
|
|
68
|
+
val services = options.optJSONArray("services") ?: throw IllegalArgumentException()
|
|
69
|
+
require(services.length() in 1..8)
|
|
70
|
+
(0 until services.length()).forEach { UUID.fromString(services.getString(it)) }
|
|
71
|
+
scanDone = done
|
|
72
|
+
if (!permitted()) {
|
|
73
|
+
val activity = context as? Activity
|
|
74
|
+
if (activity == null) { stopScan(failure("NEEDS_ACTIVITY", "Bluetooth setup needs a screen")); return }
|
|
75
|
+
permissionScan = options
|
|
76
|
+
ActivityCompat.requestPermissions(activity, permissions(), 4317)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
startScan(options)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun onPermissions(requestCode: Int) {
|
|
83
|
+
if (requestCode != 4317) return
|
|
84
|
+
val options = permissionScan ?: return
|
|
85
|
+
permissionScan = null
|
|
86
|
+
if (!permitted()) stopScan(failure("NOT_AUTHORIZED", "Allow Nearby devices to find your bottle"))
|
|
87
|
+
else try { startScan(options) } catch (_: Exception) { stopScan(failure("SCAN_FAILED", "Bluetooth scan could not start")) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private fun startScan(options: JSONObject) {
|
|
91
|
+
val radio = adapter
|
|
92
|
+
if (radio == null || !radio.isEnabled) { stopScan(failure("BLUETOOTH_OFF", "Turn on Bluetooth")); return }
|
|
93
|
+
devices.clear(); rows.clear()
|
|
94
|
+
prefix = options.optString("namePrefix")
|
|
95
|
+
val services = options.getJSONArray("services")
|
|
96
|
+
val filters = (0 until services.length()).map { ScanFilter.Builder().setServiceUuid(ParcelUuid(UUID.fromString(services.getString(it)))).build() }
|
|
97
|
+
radio.bluetoothLeScanner.startScan(filters, ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanner)
|
|
98
|
+
scanTimer = timer(options.optLong("timeoutMs", 10000).coerceIn(1000, 20000)) {
|
|
99
|
+
stopScan(CraftNativeCapabilityResult(JSONArray(rows.values.toList())))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
private val scanner = object : ScanCallback() {
|
|
103
|
+
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
104
|
+
handler.post {
|
|
105
|
+
if (scanDone == null || rows.size >= 64) return@post
|
|
106
|
+
val name = result.scanRecord?.deviceName ?: ""
|
|
107
|
+
if (!name.startsWith(prefix, ignoreCase = true)) return@post
|
|
108
|
+
val id = result.device.address
|
|
109
|
+
devices[id] = result.device
|
|
110
|
+
rows[id] = JSONObject().put("id", id).put("name", name).put("rssi", result.rssi)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
override fun onScanFailed(errorCode: Int) { handler.post { stopScan(failure("SCAN_FAILED", "Bluetooth scan failed ($errorCode)")) } }
|
|
114
|
+
}
|
|
115
|
+
private fun stopScan(result: CraftNativeCapabilityResult) {
|
|
116
|
+
if (permitted()) runCatching { adapter?.bluetoothLeScanner?.stopScan(scanner) }
|
|
117
|
+
scanTimer?.let(handler::removeCallbacks); scanTimer = null; permissionScan = null
|
|
118
|
+
val done = scanDone; scanDone = null; done?.invoke(result)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private fun connect(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
122
|
+
if (scanDone != null || connectDone != null || exchangeDone != null) { done(failure("BUSY", "A Bluetooth operation is running")); return }
|
|
123
|
+
if (!permitted()) { done(failure("NOT_AUTHORIZED", "Bluetooth permission is required")); return }
|
|
124
|
+
val device = devices[options.optString("id")] ?: throw IllegalArgumentException()
|
|
125
|
+
disconnect()
|
|
126
|
+
serviceId = UUID.fromString(options.getString("service")); writeId = UUID.fromString(options.getString("write")); notifyId = UUID.fromString(options.getString("notify"))
|
|
127
|
+
connectDone = done
|
|
128
|
+
gatt = device.connectGatt(context, false, callbacks, BluetoothDevice.TRANSPORT_LE)
|
|
129
|
+
connectTimer = timer(15000) { failConnection("TIMEOUT", "Device did not connect; wake it and try again") }
|
|
130
|
+
}
|
|
131
|
+
private fun finishConnect(result: CraftNativeCapabilityResult) {
|
|
132
|
+
connectTimer?.let(handler::removeCallbacks); connectTimer = null
|
|
133
|
+
val done = connectDone; connectDone = null; done?.invoke(result)
|
|
134
|
+
}
|
|
135
|
+
private fun failConnection(code: String, message: String) { finishConnect(failure(code, message)); disconnect() }
|
|
136
|
+
private val callbacks = object : BluetoothGattCallback() {
|
|
137
|
+
override fun onConnectionStateChange(peer: BluetoothGatt, status: Int, newState: Int) { handler.post {
|
|
138
|
+
if (peer !== gatt) return@post
|
|
139
|
+
if (status != BluetoothGatt.GATT_SUCCESS || newState == BluetoothProfile.STATE_DISCONNECTED) { failConnection("DISCONNECTED", "Bluetooth device disconnected"); return@post }
|
|
140
|
+
if (newState == BluetoothProfile.STATE_CONNECTED && !peer.requestMtu(247)) failConnection("MTU_TOO_SMALL", "Could not negotiate Bluetooth MTU")
|
|
141
|
+
} }
|
|
142
|
+
override fun onMtuChanged(peer: BluetoothGatt, value: Int, status: Int) { handler.post {
|
|
143
|
+
if (peer !== gatt) return@post
|
|
144
|
+
if (status != BluetoothGatt.GATT_SUCCESS || value < 185) { failConnection("MTU_TOO_SMALL", "Bluetooth MTU is too small for sensor history"); return@post }
|
|
145
|
+
mtu = value
|
|
146
|
+
if (!peer.discoverServices()) failConnection("CONNECT_FAILED", "Could not discover Bluetooth services")
|
|
147
|
+
} }
|
|
148
|
+
override fun onServicesDiscovered(peer: BluetoothGatt, status: Int) { handler.post {
|
|
149
|
+
if (peer !== gatt) return@post
|
|
150
|
+
val service = serviceId?.let(peer::getService)
|
|
151
|
+
writer = writeId?.let { service?.getCharacteristic(it) }; notifier = notifyId?.let { service?.getCharacteristic(it) }
|
|
152
|
+
val write = writer; val notify = notifier
|
|
153
|
+
if (status != BluetoothGatt.GATT_SUCCESS || write == null || notify == null || write.properties and (BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) == 0 || notify.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY == 0) { failConnection("UNSUPPORTED_DEVICE", "The selected GATT channel is missing"); return@post }
|
|
154
|
+
val descriptor = notify.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
|
|
155
|
+
if (descriptor == null || !peer.setCharacteristicNotification(notify, true)) { failConnection("CONNECT_FAILED", "Could not subscribe to the device"); return@post }
|
|
156
|
+
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
157
|
+
if (!peer.writeDescriptor(descriptor)) failConnection("CONNECT_FAILED", "Could not enable notifications")
|
|
158
|
+
} }
|
|
159
|
+
override fun onDescriptorWrite(peer: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { handler.post {
|
|
160
|
+
if (peer !== gatt || descriptor.characteristic !== notifier) return@post
|
|
161
|
+
if (status != BluetoothGatt.GATT_SUCCESS) { failConnection("CONNECT_FAILED", "Could not enable notifications"); return@post }
|
|
162
|
+
connected = true
|
|
163
|
+
finishConnect(CraftNativeCapabilityResult(JSONObject().put("id", peer.device.address)))
|
|
164
|
+
} }
|
|
165
|
+
override fun onCharacteristicWrite(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { handler.post {
|
|
166
|
+
if (peer === gatt && characteristic === writer && status != BluetoothGatt.GATT_SUCCESS) finishExchange(failure("WRITE_FAILED", "Bluetooth write failed"))
|
|
167
|
+
} }
|
|
168
|
+
override fun onCharacteristicChanged(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
|
169
|
+
val bytes = characteristic.value?.copyOf() ?: return
|
|
170
|
+
handler.post {
|
|
171
|
+
if (peer !== gatt || characteristic !== notifier || exchangeDone == null) return@post
|
|
172
|
+
if (bytes.size < responsePrefix.size || !bytes.take(responsePrefix.size).toByteArray().contentEquals(responsePrefix)) return@post
|
|
173
|
+
if (bytes.size > 4096) finishExchange(failure("INVALID_RESPONSE", "Bluetooth response is too large"))
|
|
174
|
+
else finishExchange(CraftNativeCapabilityResult(JSONArray(bytes.map { it.toInt() and 255 })))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
private fun byteArray(options: JSONObject, name: String, max: Int): ByteArray {
|
|
179
|
+
val values = options.getJSONArray(name)
|
|
180
|
+
require(values.length() in 1..max)
|
|
181
|
+
return ByteArray(values.length()) { index ->
|
|
182
|
+
val value = values.get(index)
|
|
183
|
+
require(value is Number && value.toDouble() == value.toInt().toDouble() && value.toInt() in 0..255)
|
|
184
|
+
value.toInt().toByte()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
private fun exchange(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
188
|
+
if (exchangeDone != null) { done(failure("BUSY", "A Bluetooth request is running")); return }
|
|
189
|
+
val peer = gatt; val write = writer
|
|
190
|
+
if (!connected || peer == null || write == null) { done(failure("DISCONNECTED", "Bluetooth device is disconnected")); return }
|
|
191
|
+
val bytes = byteArray(options, "bytes", 182)
|
|
192
|
+
require(bytes.size <= mtu - 3)
|
|
193
|
+
responsePrefix = byteArray(options, "responsePrefix", 16)
|
|
194
|
+
exchangeDone = done
|
|
195
|
+
exchangeTimer = timer(options.optLong("timeoutMs", 10000).coerceIn(1000, 20000)) { finishExchange(failure("TIMEOUT", "Bluetooth device did not answer")) }
|
|
196
|
+
write.writeType = if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT else BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
197
|
+
write.value = bytes
|
|
198
|
+
if (!peer.writeCharacteristic(write)) finishExchange(failure("WRITE_FAILED", "Bluetooth write queue is busy"))
|
|
199
|
+
}
|
|
200
|
+
private fun finishExchange(result: CraftNativeCapabilityResult) {
|
|
201
|
+
exchangeTimer?.let(handler::removeCallbacks); exchangeTimer = null; responsePrefix = byteArrayOf()
|
|
202
|
+
val done = exchangeDone; exchangeDone = null; done?.invoke(result)
|
|
203
|
+
}
|
|
204
|
+
fun disconnect() {
|
|
205
|
+
stopScan(failure("CANCELLED", "Bluetooth scan cancelled"))
|
|
206
|
+
finishConnect(failure("CANCELLED", "Bluetooth connection cancelled"))
|
|
207
|
+
finishExchange(failure("DISCONNECTED", "Bluetooth device disconnected"))
|
|
208
|
+
val old = gatt; gatt = null; connected = false; writer = null; notifier = null; mtu = 23
|
|
209
|
+
runCatching { old?.disconnect(); old?.close() }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -63,6 +63,8 @@ internal class CraftNativeCapabilities(
|
|
|
63
63
|
) {
|
|
64
64
|
/** The screen's activity; null in a background refresh, where nothing can be shown. */
|
|
65
65
|
private val activity: MainActivity? get() = context as? MainActivity
|
|
66
|
+
private val bluetooth by lazy { CraftBluetooth(context) }
|
|
67
|
+
fun onBluetoothPermissions(requestCode: Int) { if (config.optBoolean("enableBluetooth")) bluetooth.onPermissions(requestCode) }
|
|
66
68
|
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
67
69
|
private val storage = context.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
|
|
68
70
|
// The typed native renderer has no CraftNative Zig holder. Keep its
|
|
@@ -111,6 +113,7 @@ internal class CraftNativeCapabilities(
|
|
|
111
113
|
|
|
112
114
|
fun names(): JSONArray = JSONArray().apply {
|
|
113
115
|
listOf("device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "files").forEach(::put)
|
|
116
|
+
if (config.optBoolean("enableBluetooth")) put("bluetooth")
|
|
114
117
|
if (config.optBoolean("enableBiometric")) put("biometric")
|
|
115
118
|
if (config.optBoolean("enableSecureStorage")) put("secureStorage")
|
|
116
119
|
if (config.optBoolean("enableLocalDatabase")) put("database")
|
|
@@ -132,6 +135,10 @@ internal class CraftNativeCapabilities(
|
|
|
132
135
|
return
|
|
133
136
|
}
|
|
134
137
|
when (module) {
|
|
138
|
+
"Bluetooth" -> {
|
|
139
|
+
if (!config.optBoolean("enableBluetooth")) complete(requestToken, failure("CAPABILITY_DISABLED", "Bluetooth is disabled"), completion)
|
|
140
|
+
else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
|
|
141
|
+
}
|
|
135
142
|
"Storage" -> complete(requestToken, storage(method, args), completion)
|
|
136
143
|
"SecureStorage" -> {
|
|
137
144
|
if (!config.optBoolean("enableSecureStorage")) complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
|
|
@@ -177,6 +184,7 @@ internal class CraftNativeCapabilities(
|
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
fun close() {
|
|
187
|
+
if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
|
|
180
188
|
databaseExecutor.shutdownNow()
|
|
181
189
|
networkExecutor.shutdownNow()
|
|
182
190
|
fetchConnections.values.forEach(HttpURLConnection::disconnect)
|
|
@@ -856,15 +856,18 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
856
856
|
requestLayout()
|
|
857
857
|
}
|
|
858
858
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
859
|
+
companion object {
|
|
860
|
+
/** Clears the memoized measurements of every flex host above `start`. The activity calls it too (an image whose source changed). */
|
|
861
|
+
fun invalidateMeasurementAncestors(start: ViewParent?) {
|
|
862
|
+
var ancestor = start
|
|
863
|
+
while (ancestor != null) {
|
|
864
|
+
when (ancestor) {
|
|
865
|
+
is CraftNativeFlexLayout -> ancestor.invalidateMeasurements()
|
|
866
|
+
is CraftNativeKeyboardAvoidingView -> ancestor.content.invalidateMeasurements()
|
|
867
|
+
is CraftNativeScrollView -> ancestor.invalidateContentLayout()
|
|
868
|
+
}
|
|
869
|
+
ancestor = (ancestor as? View)?.parent
|
|
866
870
|
}
|
|
867
|
-
ancestor = (ancestor as? View)?.parent
|
|
868
871
|
}
|
|
869
872
|
}
|
|
870
873
|
|
|
@@ -4086,7 +4089,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4086
4089
|
includeFontPadding = false
|
|
4087
4090
|
configureText(this, craftNativeOptionalString(props.opt("title")) ?: childText(children), style)
|
|
4088
4091
|
val linkColor = craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color"))
|
|
4089
|
-
setTextColor(color(linkColor, Color.rgb(33, 150, 243)))
|
|
4092
|
+
setTextColor(color(linkColor.orEmpty(), Color.rgb(33, 150, 243)))
|
|
4090
4093
|
paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
|
|
4091
4094
|
isEnabled = !props.optBoolean("disabled", false)
|
|
4092
4095
|
isClickable = true
|
|
@@ -4369,7 +4372,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4369
4372
|
} else {
|
|
4370
4373
|
craftNativeOptionalString(events.opt("onDismiss"))
|
|
4371
4374
|
}
|
|
4372
|
-
if (lifecycleHandler.
|
|
4375
|
+
if (!lifecycleHandler.isNullOrBlank() && !suppressDismiss) {
|
|
4373
4376
|
// Deliver lifecycle callbacks after reconciliation returns to
|
|
4374
4377
|
// the queue; the handler may immediately schedule another
|
|
4375
4378
|
// render against this native view.
|
|
@@ -5452,7 +5455,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5452
5455
|
private fun imageDidChange(view: ImageView) {
|
|
5453
5456
|
view.invalidate()
|
|
5454
5457
|
view.requestLayout()
|
|
5455
|
-
invalidateMeasurementAncestors(view.parent)
|
|
5458
|
+
CraftNativeFlexLayout.invalidateMeasurementAncestors(view.parent)
|
|
5456
5459
|
}
|
|
5457
5460
|
|
|
5458
5461
|
private fun emitImageEvent(screen: Screen, view: ImageView, handler: String?, nativeEvent: JSONObject = JSONObject()) {
|
|
@@ -5774,6 +5777,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5774
5777
|
|
|
5775
5778
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
|
5776
5779
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
5780
|
+
if (::capabilities.isInitialized) capabilities.onBluetoothPermissions(requestCode)
|
|
5777
5781
|
if (::services.isInitialized) services.onRequestPermissionsResult(requestCode)
|
|
5778
5782
|
}
|
|
5779
5783
|
|
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.178";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.js
CHANGED
|
@@ -1003,6 +1003,7 @@ function writeShell(output, config) {
|
|
|
1003
1003
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
|
|
1004
1004
|
cpSync(join3(TEMPLATES_DIR, "CraftHealth.swift"), join3(output, "Sources", "CraftHealth.swift"));
|
|
1005
1005
|
cpSync(join3(TEMPLATES_DIR, "CraftMusic.swift"), join3(output, "Sources", "CraftMusic.swift"));
|
|
1006
|
+
cpSync(join3(TEMPLATES_DIR, "CraftBluetooth.swift"), join3(output, "Sources", "CraftBluetooth.swift"));
|
|
1006
1007
|
cpSync(join3(TEMPLATES_DIR, "CraftHeartRate.swift"), join3(output, "Sources", "CraftHeartRate.swift"));
|
|
1007
1008
|
cpSync(join3(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join3(output, "Sources", "CraftScheduledWorkouts.swift"));
|
|
1008
1009
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeMutation.swift"), join3(output, "Sources", "CraftNativeMutation.swift"));
|
|
@@ -24,6 +24,11 @@ export type SimulatorStep = {
|
|
|
24
24
|
} | {
|
|
25
25
|
command: 'swipe';
|
|
26
26
|
direction: 'up' | 'down' | 'left' | 'right';
|
|
27
|
+
}
|
|
28
|
+
/** The system back-swipe: a drag in from the left edge (or the right). */
|
|
29
|
+
| {
|
|
30
|
+
command: 'edge';
|
|
31
|
+
direction: 'left' | 'right';
|
|
27
32
|
} | {
|
|
28
33
|
command: 'tabbar';
|
|
29
34
|
state: 'shown' | 'hidden';
|
|
@@ -2564,6 +2564,17 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2564
2564
|
rejectCallback(callbackId, error: "Local database is disabled", code: "CAPABILITY_DISABLED")
|
|
2565
2565
|
}
|
|
2566
2566
|
// MARK: - Bluetooth
|
|
2567
|
+
case "bluetoothChannel":
|
|
2568
|
+
guard config.enableBluetooth else {
|
|
2569
|
+
rejectCallback(callbackId, error: "Bluetooth is disabled", code: "CAPABILITY_DISABLED")
|
|
2570
|
+
break
|
|
2571
|
+
}
|
|
2572
|
+
CraftBluetooth.shared.perform(method: body["method"] as? String ?? "", args: [body["options"] as? [String: Any] ?? [:]]) { [weak self] result in
|
|
2573
|
+
switch result {
|
|
2574
|
+
case .success(let value): self?.resolveCallback(callbackId, result: value)
|
|
2575
|
+
case .failure(let error): self?.rejectCallback(callbackId, error: error.message, code: error.code)
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2567
2578
|
case "startBluetoothScan":
|
|
2568
2579
|
if config.enableBluetooth {
|
|
2569
2580
|
startBluetoothScan(callbackId: callbackId)
|
|
@@ -5288,6 +5299,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5288
5299
|
// natively, screen locked or app closed, until stopRecording().
|
|
5289
5300
|
(function() {
|
|
5290
5301
|
function heartRate(method, options) { return craft._invoke('heartRate', {method: method, options: options || {}}); }
|
|
5302
|
+
craft.bluetooth = {
|
|
5303
|
+
getState: function() { return craft._invoke('bluetoothChannel', {method: 'getState'}); },
|
|
5304
|
+
scan: function(options) { return craft._invoke('bluetoothChannel', {method: 'scan', options: options || {}}); },
|
|
5305
|
+
stopScan: function() { return craft._invoke('bluetoothChannel', {method: 'stopScan'}); },
|
|
5306
|
+
connect: function(options) { return craft._invoke('bluetoothChannel', {method: 'connect', options: options || {}}); },
|
|
5307
|
+
exchange: function(options) { return craft._invoke('bluetoothChannel', {method: 'exchange', options: options || {}}); },
|
|
5308
|
+
disconnect: function() { return craft._invoke('bluetoothChannel', {method: 'disconnect'}); }
|
|
5309
|
+
};
|
|
5291
5310
|
craft.heartRate = {
|
|
5292
5311
|
getState: function() { return heartRate('getState'); },
|
|
5293
5312
|
requestAuthorization: function() { return craft._invoke('heartRateRequestAuthorization'); },
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import CoreBluetooth
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
struct CraftBluetoothError: Error {
|
|
5
|
+
let message: String
|
|
6
|
+
let code: String
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// One explicitly chosen GATT channel. Scanning is user initiated; no vendor API.
|
|
10
|
+
final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
|
11
|
+
typealias Completion = (Result<Any, CraftBluetoothError>) -> Void
|
|
12
|
+
static let shared = CraftBluetooth()
|
|
13
|
+
private var central: CBCentralManager?
|
|
14
|
+
private var devices: [String: CBPeripheral] = [:]
|
|
15
|
+
private var names: [String: String] = [:]
|
|
16
|
+
private var strengths: [String: Int] = [:]
|
|
17
|
+
private var scanServices: [CBUUID] = []
|
|
18
|
+
private var namePrefix = ""
|
|
19
|
+
private var scanCompletion: Completion?
|
|
20
|
+
private var scanTimer: DispatchWorkItem?
|
|
21
|
+
private var peripheral: CBPeripheral?
|
|
22
|
+
private var serviceUUID: CBUUID?
|
|
23
|
+
private var writeUUID: CBUUID?
|
|
24
|
+
private var notifyUUID: CBUUID?
|
|
25
|
+
private var writer: CBCharacteristic?
|
|
26
|
+
private var notifier: CBCharacteristic?
|
|
27
|
+
private var connectCompletion: Completion?
|
|
28
|
+
private var connectTimer: DispatchWorkItem?
|
|
29
|
+
private var exchangeCompletion: Completion?
|
|
30
|
+
private var exchangeTimer: DispatchWorkItem?
|
|
31
|
+
private var responsePrefix = Data()
|
|
32
|
+
private var connected = false
|
|
33
|
+
|
|
34
|
+
func perform(method: String, args: [Any], completion: @escaping Completion) {
|
|
35
|
+
if !Thread.isMainThread {
|
|
36
|
+
DispatchQueue.main.async { self.perform(method: method, args: args, completion: completion) }
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
40
|
+
switch method {
|
|
41
|
+
case "getState":
|
|
42
|
+
completion(.success(["available": true, "connected": connected, "scanning": scanCompletion != nil]))
|
|
43
|
+
case "scan": scan(options, completion)
|
|
44
|
+
case "stopScan": stopScan(); completion(.success(true))
|
|
45
|
+
case "connect": connect(options, completion)
|
|
46
|
+
case "exchange": exchange(options, completion)
|
|
47
|
+
case "disconnect": disconnect(); completion(.success(true))
|
|
48
|
+
default: reject(completion, "Unsupported Bluetooth method", "UNKNOWN_ACTION")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private func reject(_ completion: Completion, _ message: String, _ code: String) {
|
|
53
|
+
completion(.failure(CraftBluetoothError(message: message, code: code)))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func ensureCentral() {
|
|
57
|
+
if central == nil { central = CBCentralManager(delegate: self, queue: .main) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func scan(_ options: [String: Any], _ completion: @escaping Completion) {
|
|
61
|
+
guard scanCompletion == nil, connectCompletion == nil, exchangeCompletion == nil else {
|
|
62
|
+
return reject(completion, "A Bluetooth operation is already running", "BUSY")
|
|
63
|
+
}
|
|
64
|
+
guard let services = options["services"] as? [String], !services.isEmpty, services.count <= 8,
|
|
65
|
+
services.allSatisfy({ UUID(uuidString: $0) != nil }) else {
|
|
66
|
+
return reject(completion, "Scan needs full service UUIDs", "INVALID_ARGUMENT")
|
|
67
|
+
}
|
|
68
|
+
let timeout = min(20, max(1, (options["timeoutMs"] as? Double ?? 10000) / 1000))
|
|
69
|
+
scanServices = services.map(CBUUID.init(string:))
|
|
70
|
+
namePrefix = options["namePrefix"] as? String ?? ""
|
|
71
|
+
devices.removeAll(); names.removeAll(); strengths.removeAll()
|
|
72
|
+
scanCompletion = completion
|
|
73
|
+
ensureCentral()
|
|
74
|
+
if central?.state == .poweredOn { startScan() }
|
|
75
|
+
else if let state = central?.state, state != .unknown, state != .resetting { rejectRadio(state); return }
|
|
76
|
+
let timer = DispatchWorkItem { [weak self] in self?.finishScan() }
|
|
77
|
+
scanTimer = timer
|
|
78
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timer)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func startScan() {
|
|
82
|
+
guard scanCompletion != nil else { return }
|
|
83
|
+
central?.scanForPeripherals(withServices: scanServices, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func finishScan() {
|
|
87
|
+
central?.stopScan(); scanTimer?.cancel(); scanTimer = nil
|
|
88
|
+
let completion = scanCompletion; scanCompletion = nil
|
|
89
|
+
let rows: [[String: Any]] = devices.keys.sorted().map { ["id": $0, "name": names[$0] ?? "", "rssi": strengths[$0] ?? -127] }
|
|
90
|
+
completion?(.success(rows))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func stopScan() {
|
|
94
|
+
central?.stopScan(); scanTimer?.cancel(); scanTimer = nil
|
|
95
|
+
let completion = scanCompletion; scanCompletion = nil
|
|
96
|
+
if let completion { reject(completion, "Bluetooth scan cancelled", "CANCELLED") }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func rejectRadio(_ state: CBManagerState) {
|
|
100
|
+
let code = state == .unauthorized ? "NOT_AUTHORIZED" : state == .poweredOff ? "BLUETOOTH_OFF" : "UNSUPPORTED"
|
|
101
|
+
let failure = CraftBluetoothError(message: "Bluetooth is not available", code: code)
|
|
102
|
+
central?.stopScan(); scanTimer?.cancel(); scanTimer = nil
|
|
103
|
+
let scan = scanCompletion; scanCompletion = nil; scan?(.failure(failure))
|
|
104
|
+
settleConnect(.failure(failure)); settleExchange(.failure(failure))
|
|
105
|
+
connected = false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
109
|
+
if central.state == .poweredOn { startScan() }
|
|
110
|
+
else if central.state != .unknown, central.state != .resetting { rejectRadio(central.state) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
114
|
+
guard scanCompletion != nil, devices.count < 64 else { return }
|
|
115
|
+
let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name ?? ""
|
|
116
|
+
guard namePrefix.isEmpty || name.lowercased().hasPrefix(namePrefix.lowercased()) else { return }
|
|
117
|
+
let id = peripheral.identifier.uuidString
|
|
118
|
+
devices[id] = peripheral; names[id] = name; strengths[id] = RSSI.intValue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private func connect(_ options: [String: Any], _ completion: @escaping Completion) {
|
|
122
|
+
guard scanCompletion == nil, connectCompletion == nil, exchangeCompletion == nil else {
|
|
123
|
+
return reject(completion, "A Bluetooth operation is already running", "BUSY")
|
|
124
|
+
}
|
|
125
|
+
guard let id = options["id"] as? String, let found = devices[id],
|
|
126
|
+
let service = options["service"] as? String, UUID(uuidString: service) != nil,
|
|
127
|
+
let write = options["write"] as? String, UUID(uuidString: write) != nil,
|
|
128
|
+
let notify = options["notify"] as? String, UUID(uuidString: notify) != nil else {
|
|
129
|
+
return reject(completion, "Choose a nearby device and its GATT channel", "INVALID_ARGUMENT")
|
|
130
|
+
}
|
|
131
|
+
guard central?.state == .poweredOn else { return reject(completion, "Bluetooth is off", "BLUETOOTH_OFF") }
|
|
132
|
+
disconnect()
|
|
133
|
+
peripheral = found; serviceUUID = CBUUID(string: service); writeUUID = CBUUID(string: write); notifyUUID = CBUUID(string: notify)
|
|
134
|
+
found.delegate = self; connectCompletion = completion
|
|
135
|
+
central?.connect(found, options: nil)
|
|
136
|
+
let timer = DispatchWorkItem { [weak self] in
|
|
137
|
+
guard let self else { return }
|
|
138
|
+
self.settleConnect(.failure(CraftBluetoothError(message: "Device did not connect; wake it and try again", code: "TIMEOUT")))
|
|
139
|
+
self.disconnect()
|
|
140
|
+
}
|
|
141
|
+
connectTimer = timer
|
|
142
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timer)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private func settleConnect(_ result: Result<Any, CraftBluetoothError>) {
|
|
146
|
+
connectTimer?.cancel(); connectTimer = nil
|
|
147
|
+
let completion = connectCompletion; connectCompletion = nil; completion?(result)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
151
|
+
guard peripheral === self.peripheral, let serviceUUID else { return }
|
|
152
|
+
peripheral.discoverServices([serviceUUID])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
156
|
+
guard peripheral === self.peripheral else { return }
|
|
157
|
+
settleConnect(.failure(CraftBluetoothError(message: "Bluetooth connection failed", code: "CONNECT_FAILED")))
|
|
158
|
+
disconnect()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
162
|
+
guard peripheral === self.peripheral else { return }
|
|
163
|
+
connected = false; writer = nil; notifier = nil
|
|
164
|
+
settleConnect(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
|
|
165
|
+
settleExchange(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
169
|
+
guard peripheral === self.peripheral else { return }
|
|
170
|
+
guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == serviceUUID }), let writeUUID, let notifyUUID else {
|
|
171
|
+
settleConnect(.failure(CraftBluetoothError(message: "The selected GATT service is missing", code: "UNSUPPORTED_DEVICE"))); disconnect(); return
|
|
172
|
+
}
|
|
173
|
+
peripheral.discoverCharacteristics([writeUUID, notifyUUID], for: service)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
177
|
+
guard peripheral === self.peripheral else { return }
|
|
178
|
+
writer = service.characteristics?.first(where: { $0.uuid == writeUUID })
|
|
179
|
+
notifier = service.characteristics?.first(where: { $0.uuid == notifyUUID })
|
|
180
|
+
guard error == nil, let writer, let notifier, writer.properties.contains(.write) || writer.properties.contains(.writeWithoutResponse), notifier.properties.contains(.notify) else {
|
|
181
|
+
settleConnect(.failure(CraftBluetoothError(message: "The selected GATT channel is missing", code: "UNSUPPORTED_DEVICE"))); disconnect(); return
|
|
182
|
+
}
|
|
183
|
+
peripheral.setNotifyValue(true, for: notifier)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
187
|
+
guard peripheral === self.peripheral, characteristic === notifier else { return }
|
|
188
|
+
guard error == nil, characteristic.isNotifying else {
|
|
189
|
+
settleConnect(.failure(CraftBluetoothError(message: "Could not subscribe to the device", code: "CONNECT_FAILED"))); disconnect(); return
|
|
190
|
+
}
|
|
191
|
+
connected = true
|
|
192
|
+
let id = peripheral.identifier.uuidString
|
|
193
|
+
settleConnect(.success(["id": id, "name": names[id] ?? peripheral.name ?? ""]))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private func exchange(_ options: [String: Any], _ completion: @escaping Completion) {
|
|
197
|
+
guard exchangeCompletion == nil else { return reject(completion, "A Bluetooth request is already running", "BUSY") }
|
|
198
|
+
guard connected, let peripheral, let writer else { return reject(completion, "Bluetooth device is disconnected", "DISCONNECTED") }
|
|
199
|
+
guard let bytes = options["bytes"] as? [Int], !bytes.isEmpty, bytes.count <= 182, bytes.allSatisfy({ (0...255).contains($0) }),
|
|
200
|
+
let prefix = options["responsePrefix"] as? [Int], !prefix.isEmpty, prefix.count <= 16, prefix.allSatisfy({ (0...255).contains($0) }) else {
|
|
201
|
+
return reject(completion, "Invalid Bluetooth request bytes", "INVALID_ARGUMENT")
|
|
202
|
+
}
|
|
203
|
+
let type: CBCharacteristicWriteType = writer.properties.contains(.write) ? .withResponse : .withoutResponse
|
|
204
|
+
guard bytes.count <= peripheral.maximumWriteValueLength(for: type) else { return reject(completion, "Bluetooth request exceeds the negotiated MTU", "MTU_TOO_SMALL") }
|
|
205
|
+
if type == .withoutResponse, !peripheral.canSendWriteWithoutResponse { return reject(completion, "Bluetooth write queue is busy; retry", "BUSY") }
|
|
206
|
+
responsePrefix = Data(prefix.map(UInt8.init)); exchangeCompletion = completion
|
|
207
|
+
let timeout = min(20, max(1, (options["timeoutMs"] as? Double ?? 10000) / 1000))
|
|
208
|
+
let timer = DispatchWorkItem { [weak self] in self?.settleExchange(.failure(CraftBluetoothError(message: "Bluetooth device did not answer", code: "TIMEOUT"))) }
|
|
209
|
+
exchangeTimer = timer
|
|
210
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timer)
|
|
211
|
+
peripheral.writeValue(Data(bytes.map(UInt8.init)), for: writer, type: type)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func settleExchange(_ result: Result<Any, CraftBluetoothError>) {
|
|
215
|
+
exchangeTimer?.cancel(); exchangeTimer = nil
|
|
216
|
+
let completion = exchangeCompletion; exchangeCompletion = nil; responsePrefix = Data(); completion?(result)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
220
|
+
guard peripheral === self.peripheral, characteristic === writer, error != nil else { return }
|
|
221
|
+
settleExchange(.failure(CraftBluetoothError(message: "Bluetooth write failed", code: "WRITE_FAILED")))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
225
|
+
guard peripheral === self.peripheral, characteristic === notifier, exchangeCompletion != nil else { return }
|
|
226
|
+
guard error == nil, let data = characteristic.value else {
|
|
227
|
+
settleExchange(.failure(CraftBluetoothError(message: "Bluetooth notification failed", code: "READ_FAILED"))); return
|
|
228
|
+
}
|
|
229
|
+
guard data.starts(with: responsePrefix) else { return }
|
|
230
|
+
guard data.count <= 4096 else {
|
|
231
|
+
settleExchange(.failure(CraftBluetoothError(message: "Bluetooth response is too large", code: "INVALID_RESPONSE"))); return
|
|
232
|
+
}
|
|
233
|
+
settleExchange(.success(data.map(Int.init)))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private func disconnect() {
|
|
237
|
+
stopScan()
|
|
238
|
+
settleConnect(.failure(CraftBluetoothError(message: "Bluetooth connection cancelled", code: "CANCELLED")))
|
|
239
|
+
settleExchange(.failure(CraftBluetoothError(message: "Bluetooth request cancelled", code: "CANCELLED")))
|
|
240
|
+
if let peripheral { central?.cancelPeripheralConnection(peripheral); peripheral.delegate = nil }
|
|
241
|
+
peripheral = nil; writer = nil; notifier = nil; connected = false
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -70,6 +70,7 @@ enum CraftNativeActions {
|
|
|
70
70
|
// A heart-rate strap is the app's own service (CraftHeartRate.swift),
|
|
71
71
|
// so it needs no web view.
|
|
72
72
|
if config.enableHeartRate { values.append("heartRate") }
|
|
73
|
+
if config.enableBluetooth { values.append("bluetooth") }
|
|
73
74
|
// Workouts on the paired Apple Watch (CraftScheduledWorkouts.swift),
|
|
74
75
|
// also the app's own service.
|
|
75
76
|
if config.enableScheduledWorkouts { values.append("scheduledWorkouts") }
|
|
@@ -401,6 +402,15 @@ enum CraftNativeActions {
|
|
|
401
402
|
}
|
|
402
403
|
CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
|
|
403
404
|
return
|
|
405
|
+
case ("Bluetooth", _):
|
|
406
|
+
guard config.enableBluetooth else {
|
|
407
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Bluetooth is disabled"), completion)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
CraftBluetooth.shared.perform(method: method, args: args) { result in
|
|
411
|
+
complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
|
|
412
|
+
}
|
|
413
|
+
return
|
|
404
414
|
case ("HeartRate", _):
|
|
405
415
|
// A Bluetooth heart-rate strap, the same service as a page's
|
|
406
416
|
// `craft.heartRate`: one connection and one recording, whichever
|
|
@@ -270,14 +270,24 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
270
270
|
|
|
271
271
|
@objc private func keyboardTap() {
|
|
272
272
|
guard keyboardShouldPersistTaps != "always" else { return }
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
273
|
+
let point = keyboardTapGesture.location(in: self)
|
|
274
|
+
var hit = hitTest(point, with: nil)
|
|
275
|
+
var onControl = false
|
|
276
|
+
var onButton = false
|
|
277
|
+
while let view = hit, view !== self {
|
|
278
|
+
if view is UIButton { onButton = true }
|
|
279
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
|
|
280
|
+
hit = view.superview
|
|
280
281
|
}
|
|
282
|
+
if keyboardShouldPersistTaps == "handled", onControl { return }
|
|
283
|
+
// A button keeps the keyboard: its press gives the focus straight
|
|
284
|
+
// back. Ending editing first only let the keyboard's inset go while
|
|
285
|
+
// the finger was down, the button moved out from under it, and the
|
|
286
|
+
// press was lost.
|
|
287
|
+
if onButton { return }
|
|
288
|
+
// Otherwise the keyboard goes; after a tap on nothing, for good (the
|
|
289
|
+
// next render must not bring the last input back).
|
|
290
|
+
if !onControl { craftScreenController?.forgetFocusedInput() }
|
|
281
291
|
window?.endEditing(true)
|
|
282
292
|
}
|
|
283
293
|
|
|
@@ -1166,6 +1166,18 @@ enum CraftNativeFlexLayout {
|
|
|
1166
1166
|
/// grid. It is a UIStackView in name only, so existing callers and tests keep
|
|
1167
1167
|
/// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
|
|
1168
1168
|
/// constraints are never created, and every frame comes from `layout`.
|
|
1169
|
+
extension UIView {
|
|
1170
|
+
/// The native screen this view is drawn on, through the responder chain.
|
|
1171
|
+
var craftScreenController: CraftNativeScreenController? {
|
|
1172
|
+
var responder: UIResponder? = next
|
|
1173
|
+
while let current = responder {
|
|
1174
|
+
if let screen = current as? CraftNativeScreenController { return screen }
|
|
1175
|
+
responder = current.next
|
|
1176
|
+
}
|
|
1177
|
+
return nil
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1169
1181
|
private class CraftNativeFlowView: UIStackView {
|
|
1170
1182
|
var pressActiveOpacity: CGFloat?
|
|
1171
1183
|
var pressBaseOpacity: CGFloat = 1
|
|
@@ -1358,6 +1370,13 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1358
1370
|
craftApplyCornerRadius()
|
|
1359
1371
|
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
1360
1372
|
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
1373
|
+
var moved = false
|
|
1374
|
+
defer {
|
|
1375
|
+
// The screen reports onLayout from its own layout pass, which a
|
|
1376
|
+
// change deep in a scroll view's content never runs: a view that
|
|
1377
|
+
// went from 240 to 180 wide kept saying 240.
|
|
1378
|
+
if moved { craftScreenController?.scheduleLayoutEvents() }
|
|
1379
|
+
}
|
|
1361
1380
|
for (child, frame) in result.frames {
|
|
1362
1381
|
var viewportFrame = frame
|
|
1363
1382
|
if child === viewportChild, viewportChildFillsHeight {
|
|
@@ -1372,14 +1391,14 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1372
1391
|
snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
|
|
1373
1392
|
}
|
|
1374
1393
|
if child.transform.isIdentity {
|
|
1375
|
-
if child.frame != snapped { child.frame = snapped }
|
|
1394
|
+
if child.frame != snapped { child.frame = snapped; moved = true }
|
|
1376
1395
|
} else {
|
|
1377
1396
|
// A transformed child (a sticky header, a scaled spinner) is
|
|
1378
1397
|
// placed by bounds and center: its frame is undefined then.
|
|
1379
1398
|
let size = CGRect(origin: child.bounds.origin, size: snapped.size)
|
|
1380
1399
|
let center = CGPoint(x: snapped.midX, y: snapped.midY)
|
|
1381
|
-
if child.bounds != size { child.bounds = size }
|
|
1382
|
-
if child.center != center { child.center = center }
|
|
1400
|
+
if child.bounds != size { child.bounds = size; moved = true }
|
|
1401
|
+
if child.center != center { child.center = center; moved = true }
|
|
1383
1402
|
}
|
|
1384
1403
|
child.craftApplyCornerRadius()
|
|
1385
1404
|
}
|
|
@@ -2192,6 +2211,10 @@ private final class CraftNativeModalView: UIView {
|
|
|
2192
2211
|
|
|
2193
2212
|
required init?(coder: NSCoder) { nil }
|
|
2194
2213
|
|
|
2214
|
+
/// Whether the modal takes touches is its own to say: shown, it takes
|
|
2215
|
+
/// every one (the scrim and its controls); the screen's press and long
|
|
2216
|
+
/// press bookkeeping once turned that off for a view with no handler of
|
|
2217
|
+
/// its own, and a shown modal let every tap through to the screen behind.
|
|
2195
2218
|
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
2196
2219
|
if visible { isUserInteractionEnabled = true }
|
|
2197
2220
|
if renderedVisible == visible && isHidden == !visible {
|
|
@@ -2273,7 +2296,27 @@ private final class CraftNativeModalView: UIView {
|
|
|
2273
2296
|
override func layoutSubviews() {
|
|
2274
2297
|
super.layoutSubviews()
|
|
2275
2298
|
blocker.frame = bounds
|
|
2276
|
-
|
|
2299
|
+
// The scrim covers the whole screen; what the modal holds stays
|
|
2300
|
+
// clear of the bars and the home indicator it is drawn over.
|
|
2301
|
+
contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/// Where the screen's own content starts and ends: the scrim covers the
|
|
2305
|
+
/// whole screen, what the modal holds stays clear of the bars.
|
|
2306
|
+
var contentInsets: UIEdgeInsets = .zero {
|
|
2307
|
+
didSet { if contentInsets != oldValue { setNeedsLayout() } }
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/// The delegate of a Pressable's press-in/out feedback. That recognizer is
|
|
2312
|
+
/// a long press of no duration, so it begins the moment a finger lands; on its
|
|
2313
|
+
/// own it would claim the touch, and the tap that is the Pressable's onPress
|
|
2314
|
+
/// failed whenever onPressIn or onPressOut were set too. It only watches.
|
|
2315
|
+
final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
2316
|
+
static let shared = CraftPressFeedbackDelegate()
|
|
2317
|
+
|
|
2318
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
2319
|
+
true
|
|
2277
2320
|
}
|
|
2278
2321
|
}
|
|
2279
2322
|
|
|
@@ -2545,14 +2588,24 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
2545
2588
|
|
|
2546
2589
|
@objc private func keyboardTap() {
|
|
2547
2590
|
guard keyboardShouldPersistTaps != "always" else { return }
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
}
|
|
2555
|
-
|
|
2591
|
+
let point = keyboardTapGesture.location(in: self)
|
|
2592
|
+
var hit = hitTest(point, with: nil)
|
|
2593
|
+
var onControl = false
|
|
2594
|
+
var onButton = false
|
|
2595
|
+
while let view = hit, view !== self {
|
|
2596
|
+
if view is UIButton { onButton = true }
|
|
2597
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
|
|
2598
|
+
hit = view.superview
|
|
2599
|
+
}
|
|
2600
|
+
if keyboardShouldPersistTaps == "handled", onControl { return }
|
|
2601
|
+
// A button keeps the keyboard: its press gives the focus straight
|
|
2602
|
+
// back. Ending editing first only let the keyboard's inset go while
|
|
2603
|
+
// the finger was down, the button moved out from under it, and the
|
|
2604
|
+
// press was lost.
|
|
2605
|
+
if onButton { return }
|
|
2606
|
+
// Otherwise the keyboard goes; after a tap on nothing, for good (the
|
|
2607
|
+
// next render must not bring the last input back).
|
|
2608
|
+
if !onControl { craftScreenController?.forgetFocusedInput() }
|
|
2556
2609
|
window?.endEditing(true)
|
|
2557
2610
|
}
|
|
2558
2611
|
|
|
@@ -3056,6 +3109,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3056
3109
|
private var linkTargets: [ObjectIdentifier: String] = [:]
|
|
3057
3110
|
private var renderedRoot: RenderedNode?
|
|
3058
3111
|
private weak var lastFocusedInput: UIView?
|
|
3112
|
+
|
|
3113
|
+
/// The person put the keyboard away (Return, or a tap on nothing in a
|
|
3114
|
+
/// scroll view): the last input is let go. Kept, the next render or
|
|
3115
|
+
/// button press focused it again and the keyboard came straight back.
|
|
3116
|
+
func forgetFocusedInput(_ input: UIView? = nil) {
|
|
3117
|
+
if input == nil || lastFocusedInput === input { lastFocusedInput = nil }
|
|
3118
|
+
}
|
|
3059
3119
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
3060
3120
|
private var flatListOwners: [String: String] = [:]
|
|
3061
3121
|
/// A drawing's shapes are not views: a change to one redraws its `Svg`.
|
|
@@ -3192,6 +3252,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3192
3252
|
if !isAppScript, let routeName, !routeName.isEmpty {
|
|
3193
3253
|
CraftCrashReporter.shared.screenAppeared(routeName)
|
|
3194
3254
|
}
|
|
3255
|
+
// onLayout reports views that are on screen: a pushed screen lays
|
|
3256
|
+
// out before it has a window, and nothing moved again once it had
|
|
3257
|
+
// one, so its rows never reported.
|
|
3258
|
+
emitLayoutEvents()
|
|
3195
3259
|
}
|
|
3196
3260
|
|
|
3197
3261
|
override func viewDidDisappear(_ animated: Bool) {
|
|
@@ -3266,9 +3330,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3266
3330
|
|
|
3267
3331
|
override func viewDidLayoutSubviews() {
|
|
3268
3332
|
super.viewDidLayoutSubviews()
|
|
3333
|
+
positionModals()
|
|
3269
3334
|
emitLayoutEvents()
|
|
3270
3335
|
}
|
|
3271
3336
|
|
|
3337
|
+
/// Modals cover the screen; their content starts below its bars (the
|
|
3338
|
+
/// status bar is all the safe area counts on a native screen, and a
|
|
3339
|
+
/// modal's first control sat under the navigation bar).
|
|
3340
|
+
private func positionModals() {
|
|
3341
|
+
let modals = view.subviews.compactMap { $0 as? CraftNativeModalView }
|
|
3342
|
+
guard !modals.isEmpty else { return }
|
|
3343
|
+
var insets = view.safeAreaInsets
|
|
3344
|
+
// The navigation bar floats over the screen without counting in its
|
|
3345
|
+
// safe area, and it is drawn above anything the screen adds.
|
|
3346
|
+
if let nav = navigationController, !nav.isNavigationBarHidden, nav.navigationBar.window != nil {
|
|
3347
|
+
let bar = nav.navigationBar.convert(nav.navigationBar.bounds, to: view)
|
|
3348
|
+
if bar.intersects(view.bounds) { insets.top = max(insets.top, bar.maxY) }
|
|
3349
|
+
}
|
|
3350
|
+
for modal in modals {
|
|
3351
|
+
if modal.frame != view.bounds { modal.frame = view.bounds }
|
|
3352
|
+
modal.contentInsets = insets
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3272
3356
|
private static func safeJavaScriptString(_ value: JSValue?) -> String? {
|
|
3273
3357
|
guard let value, !value.isUndefined, !value.isNull, value.isString else { return nil }
|
|
3274
3358
|
return value.toString()
|
|
@@ -4469,6 +4553,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4469
4553
|
guard let target = drawnTarget(id) else { return false }
|
|
4470
4554
|
guard applied.insert(target).inserted else { continue }
|
|
4471
4555
|
guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
|
|
4556
|
+
// A node that needs a new view (a TextInput turned multiline) is
|
|
4557
|
+
// its parent's to swap in: a targeted update only redraws in
|
|
4558
|
+
// place, and the new view was made and never attached.
|
|
4559
|
+
guard canReuseView(document, previous: previous) else { return false }
|
|
4472
4560
|
let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
|
|
4473
4561
|
// A style patch is applied in place, layout included: the parent
|
|
4474
4562
|
// reads the node's new layout style on its next pass.
|
|
@@ -4564,6 +4652,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4564
4652
|
return nil
|
|
4565
4653
|
}
|
|
4566
4654
|
|
|
4655
|
+
/// Whether `previous`'s view can draw `node`: the same type, and for a
|
|
4656
|
+
/// TextInput the same kind of field (multiline is a UITextView).
|
|
4657
|
+
private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
|
|
4658
|
+
let type = canonicalType(node["type"] as? String ?? "View")
|
|
4659
|
+
guard previous.type == type else { return false }
|
|
4660
|
+
guard type == "TextInput" else { return true }
|
|
4661
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
4662
|
+
return (previous.view is UITextView) == (props["multiline"] as? Bool == true)
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4567
4665
|
private func restoreFocus(_ focused: UIView?) {
|
|
4568
4666
|
guard let focused, focused.window != nil, !focused.isFirstResponder else { return }
|
|
4569
4667
|
_ = focused.becomeFirstResponder()
|
|
@@ -4576,9 +4674,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4576
4674
|
let events = node["events"] as? [String: String] ?? [:]
|
|
4577
4675
|
let children = node["children"] as? [Any] ?? []
|
|
4578
4676
|
let current: RenderedNode
|
|
4579
|
-
let
|
|
4580
|
-
let canReuseInput = type != "TextInput" || ((previous?.view is UITextView) == wantsMultilineTextView)
|
|
4581
|
-
if let previous = previous, previous.identity == identity, previous.type == type, canReuseInput {
|
|
4677
|
+
if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
|
|
4582
4678
|
current = previous
|
|
4583
4679
|
} else {
|
|
4584
4680
|
current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
|
|
@@ -4796,6 +4892,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4796
4892
|
let modalID = ObjectIdentifier(modal)
|
|
4797
4893
|
let wasVisible = modalVisibility[modalID]
|
|
4798
4894
|
modalVisibility[modalID] = visible
|
|
4895
|
+
if visible, wasVisible != true { positionModals() }
|
|
4799
4896
|
modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
4800
4897
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
4801
4898
|
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
@@ -5199,14 +5296,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5199
5296
|
}
|
|
5200
5297
|
for child in next where child.type == "Modal" {
|
|
5201
5298
|
guard let modal = child.view as? CraftNativeModalView else { continue }
|
|
5202
|
-
|
|
5299
|
+
// A modal covers the screen, wherever it is declared. Laid over
|
|
5300
|
+
// its parent instead, one declared in a scroll view covered the
|
|
5301
|
+
// whole content: its controls sat wherever the content had
|
|
5302
|
+
// scrolled to, under the bar or off screen, out of reach.
|
|
5303
|
+
if modal.superview !== view {
|
|
5203
5304
|
modal.removeFromSuperview()
|
|
5204
|
-
|
|
5305
|
+
view.addSubview(modal)
|
|
5205
5306
|
}
|
|
5206
|
-
modal.frame = stack.bounds
|
|
5207
5307
|
modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
5208
|
-
|
|
5308
|
+
view.bringSubviewToFront(modal)
|
|
5209
5309
|
}
|
|
5310
|
+
positionModals()
|
|
5210
5311
|
parent.children = next
|
|
5211
5312
|
addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
|
|
5212
5313
|
}
|
|
@@ -5326,7 +5427,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5326
5427
|
}
|
|
5327
5428
|
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
5328
5429
|
let id = ObjectIdentifier(node.view)
|
|
5329
|
-
if let modal = node.view as? CraftNativeModalView {
|
|
5430
|
+
if let modal = node.view as? CraftNativeModalView {
|
|
5431
|
+
modal.resetForTeardown()
|
|
5432
|
+
// It sits on the screen, not in its parent: leaving with the
|
|
5433
|
+
// parent's subtree does not take it away.
|
|
5434
|
+
modal.removeFromSuperview()
|
|
5435
|
+
}
|
|
5330
5436
|
handlers.removeValue(forKey: id)
|
|
5331
5437
|
linkTargets.removeValue(forKey: id)
|
|
5332
5438
|
pressInHandlers.removeValue(forKey: id)
|
|
@@ -5401,6 +5507,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5401
5507
|
emitLayoutEvents(for: renderedRoot)
|
|
5402
5508
|
}
|
|
5403
5509
|
|
|
5510
|
+
private var layoutEventsScheduled = false
|
|
5511
|
+
|
|
5512
|
+
/// A view inside the screen moved: report onLayout once the pass that
|
|
5513
|
+
/// moved it is over, however deep it was.
|
|
5514
|
+
fileprivate func scheduleLayoutEvents() {
|
|
5515
|
+
guard !layoutEventsScheduled, !layoutHandlers.isEmpty else { return }
|
|
5516
|
+
layoutEventsScheduled = true
|
|
5517
|
+
DispatchQueue.main.async { [weak self] in
|
|
5518
|
+
guard let self else { return }
|
|
5519
|
+
self.layoutEventsScheduled = false
|
|
5520
|
+
self.emitLayoutEvents()
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5523
|
+
|
|
5404
5524
|
private func emitLayoutEvents(for node: RenderedNode) {
|
|
5405
5525
|
let id = ObjectIdentifier(node.view)
|
|
5406
5526
|
if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
|
|
@@ -5463,7 +5583,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5463
5583
|
}
|
|
5464
5584
|
// A container keeps receiving touches without a handler of its own,
|
|
5465
5585
|
// or every control inside it (and the screen root) would be dead.
|
|
5466
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5586
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5467
5587
|
}
|
|
5468
5588
|
|
|
5469
5589
|
private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
|
|
@@ -5472,7 +5592,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5472
5592
|
if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
|
|
5473
5593
|
guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
|
|
5474
5594
|
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
|
|
5475
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5595
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5476
5596
|
return
|
|
5477
5597
|
}
|
|
5478
5598
|
if pressFeedbackRecognizers[id] == nil {
|
|
@@ -5480,17 +5600,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5480
5600
|
recognizer.minimumPressDuration = 0
|
|
5481
5601
|
recognizer.allowableMovement = 10
|
|
5482
5602
|
recognizer.cancelsTouchesInView = false
|
|
5603
|
+
recognizer.delegate = CraftPressFeedbackDelegate.shared
|
|
5483
5604
|
pressFeedbackRecognizers[id] = recognizer
|
|
5484
5605
|
view.addGestureRecognizer(recognizer)
|
|
5485
5606
|
}
|
|
5486
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
5607
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
|
|
5487
5608
|
}
|
|
5488
5609
|
|
|
5489
5610
|
private func updateLongPressHandler(_ handler: String?, for view: UIView) {
|
|
5490
5611
|
let id = ObjectIdentifier(view)
|
|
5491
5612
|
if let handler, !handler.isEmpty {
|
|
5492
5613
|
longPressHandlers[id] = handler
|
|
5493
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
5614
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
|
|
5494
5615
|
if longPressRecognizers[id] == nil {
|
|
5495
5616
|
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewLongPressed(_:)))
|
|
5496
5617
|
longPressRecognizers[id] = recognizer
|
|
@@ -5501,7 +5622,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5501
5622
|
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
5502
5623
|
view.removeGestureRecognizer(recognizer)
|
|
5503
5624
|
}
|
|
5504
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
|
|
5625
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
|
|
5505
5626
|
}
|
|
5506
5627
|
}
|
|
5507
5628
|
|
|
@@ -6116,6 +6237,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6116
6237
|
if state["checked"] as? Bool == true { traits.insert(.selected) }
|
|
6117
6238
|
}
|
|
6118
6239
|
if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
|
|
6240
|
+
// A disabled Pressable or touchable takes no touches; it says so too.
|
|
6241
|
+
if props["disabled"] as? Bool == true { traits.insert(.notEnabled) }
|
|
6119
6242
|
if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
|
|
6120
6243
|
if checkbox.isSelected { traits.insert(.selected) }
|
|
6121
6244
|
view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
|
|
@@ -6266,13 +6389,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6266
6389
|
let value = snappedSliderValue(sender.value, for: sender)
|
|
6267
6390
|
if sender.value != value { sender.value = value }
|
|
6268
6391
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
6269
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value)]])
|
|
6392
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value, step: sliderSteps[ObjectIdentifier(sender)])]])
|
|
6270
6393
|
}
|
|
6271
6394
|
|
|
6272
|
-
/// A slider's value as the script reads it
|
|
6273
|
-
///
|
|
6274
|
-
|
|
6275
|
-
|
|
6395
|
+
/// A slider's value as the script reads it. UISlider keeps a Float, and a
|
|
6396
|
+
/// step of 0.1 counted up in Floats lands beside the decimal (0.8 read as
|
|
6397
|
+
/// 0.800000011920929, nine steps as 0.90000004), so the value is the
|
|
6398
|
+
/// shortest decimal for that Float, rounded to as many places as the step
|
|
6399
|
+
/// has.
|
|
6400
|
+
static func sliderEventValue(_ value: Float, step: Float? = nil) -> Double {
|
|
6401
|
+
let decimal = Double(value.description) ?? Double(value)
|
|
6402
|
+
guard let step, step > 0, step.isFinite else { return decimal }
|
|
6403
|
+
// "0.1" is one place, "0.25" two, "1.0" none; a step as small as
|
|
6404
|
+
// "1e-07" is held to six.
|
|
6405
|
+
let fraction = step.description.split(separator: ".").dropFirst().first
|
|
6406
|
+
let places = step.description.contains("e") ? 6 : min(6, fraction.map { $0 == "0" ? 0 : $0.count } ?? 0)
|
|
6407
|
+
let scale = pow(10, Double(places))
|
|
6408
|
+
return (decimal * scale).rounded() / scale
|
|
6276
6409
|
}
|
|
6277
6410
|
|
|
6278
6411
|
/// `@change` on a DatePicker: the value as `<input type="date|time|
|
|
@@ -6378,7 +6511,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6378
6511
|
|
|
6379
6512
|
@objc private func sliderFinished(_ sender: UISlider) {
|
|
6380
6513
|
guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
|
|
6381
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender))]])
|
|
6514
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
|
|
6382
6515
|
}
|
|
6383
6516
|
|
|
6384
6517
|
func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
|
|
@@ -6423,6 +6556,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6423
6556
|
}
|
|
6424
6557
|
|
|
6425
6558
|
@objc private func textSubmitted(_ sender: UITextField) {
|
|
6559
|
+
// Return put the keyboard away.
|
|
6560
|
+
forgetFocusedInput(sender)
|
|
6426
6561
|
guard let handler = submitHandlers[ObjectIdentifier(sender)] else { return }
|
|
6427
6562
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
6428
6563
|
}
|
|
@@ -286,6 +286,14 @@ final class DriverTests: XCTestCase {
|
|
|
286
286
|
case "left": app.swipeLeft()
|
|
287
287
|
default: app.swipeRight()
|
|
288
288
|
}
|
|
289
|
+
case "edge":
|
|
290
|
+
// The system back-swipe: a drag in from the left edge (or the
|
|
291
|
+
// right one with `edge right`), which a centred swipe never is.
|
|
292
|
+
let fromRight = arg == "right"
|
|
293
|
+
let start = app.coordinate(withNormalizedOffset: CGVector(dx: fromRight ? 0.995 : 0.005, dy: 0.5))
|
|
294
|
+
let end = app.coordinate(withNormalizedOffset: CGVector(dx: fromRight ? 0.15 : 0.85, dy: 0.5))
|
|
295
|
+
start.press(forDuration: 0.05, thenDragTo: end)
|
|
296
|
+
note("edge \(fromRight ? "right" : "left"): ok")
|
|
289
297
|
case "audit":
|
|
290
298
|
var issues: [String] = []
|
|
291
299
|
do {
|