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