craft-native 0.0.244 → 0.0.246
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 +20 -0
- package/dist/android/src/index.js +5 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +36 -2
- package/dist/android/templates/CraftNativeHaptics.kt.template +124 -0
- package/dist/android/templates/CraftNativeShell.kt.template +282 -72
- package/dist/android/templates/MainActivityNative.kt.template +8 -7
- package/dist/api/mobile.d.ts +71 -3
- package/dist/browser/storage.d.ts +11 -0
- package/dist/browser.d.ts +9 -0
- package/dist/browser.js +66 -13
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +32 -1
- package/dist/ios/src/index.js +6 -0
- package/dist/ios/templates/CraftApp.swift +8 -0
- package/dist/ios/templates/CraftHybrid.swift +192 -16
- package/dist/ios/templates/CraftNativeActions.swift +63 -5
- package/dist/ios/templates/CraftNativeFlatList.swift +13 -6
- package/dist/ios/templates/CraftNativeScreen.swift +396 -66
- package/dist/ios/templates/CraftWebAudio.swift +21 -13
- package/dist/mobile-testing.js +9 -3
- package/dist/mobile.js +33 -3
- package/package.json +1 -1
|
@@ -11,6 +11,20 @@ export type { AndroidMapProvider } from './native-map.js';
|
|
|
11
11
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
12
12
|
*/
|
|
13
13
|
export declare const nativeRouteBundles = true;
|
|
14
|
+
/**
|
|
15
|
+
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
|
16
|
+
* `sharedRuntime`) in each screen's context before the route's own file.
|
|
17
|
+
* Stacks asks stx for it only when this is true: a route file built so
|
|
18
|
+
* refuses to run without it.
|
|
19
|
+
*/
|
|
20
|
+
export declare const nativeRouteRuntime = true;
|
|
21
|
+
/**
|
|
22
|
+
* The app gives a screen its messages a language at a time (stx's
|
|
23
|
+
* `messages`, `native-screens/__messages/`): the areas its route uses,
|
|
24
|
+
* evaluated ahead of it (an isolate cannot read a file when asked). Stacks leaves the languages
|
|
25
|
+
* out of the screens' code only when this is true.
|
|
26
|
+
*/
|
|
27
|
+
export declare const nativeMessages = true;
|
|
14
28
|
/** Providers this published builder can generate as real Android map views. */
|
|
15
29
|
export declare const nativeMapProviders: readonly ['google', 'maplibre'];
|
|
16
30
|
export { runKotlinFixture } from './testing.js';
|
|
@@ -118,6 +132,12 @@ export interface CraftAndroidConfig {
|
|
|
118
132
|
selectedIconPulse?: boolean;
|
|
119
133
|
}>;
|
|
120
134
|
nativeApp?: string;
|
|
135
|
+
/**
|
|
136
|
+
* The secure-storage key the signed-in account's token lives under
|
|
137
|
+
* (default `auth.token`). `craft.session` follows it on every native
|
|
138
|
+
* screen, and a change of it is `SESSION_CHANGED { key, token, previous }`.
|
|
139
|
+
*/
|
|
140
|
+
sessionTokenKey?: string;
|
|
121
141
|
/** Wake the app script every so often (`craftBackgroundRefresh`), through WorkManager. */
|
|
122
142
|
backgroundRefresh?: {
|
|
123
143
|
enabled?: boolean;
|
|
@@ -360,6 +360,8 @@ async function createAndroidEmulator(options) {
|
|
|
360
360
|
|
|
361
361
|
// ../android/src/index.ts
|
|
362
362
|
var nativeRouteBundles = true;
|
|
363
|
+
var nativeRouteRuntime = true;
|
|
364
|
+
var nativeMessages = true;
|
|
363
365
|
var nativeMapProviders = ["google", "maplibre"];
|
|
364
366
|
function syncNativeFonts(fonts, output) {
|
|
365
367
|
const destination = join3(output, "app/src/main/assets/NativeFonts");
|
|
@@ -808,6 +810,7 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
|
|
|
808
810
|
write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
|
|
809
811
|
write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
|
|
810
812
|
write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
|
|
813
|
+
write("CraftNativeHaptics.kt.template", "CraftNativeHaptics.kt");
|
|
811
814
|
write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
|
|
812
815
|
write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
|
|
813
816
|
write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
|
|
@@ -1379,7 +1382,9 @@ export {
|
|
|
1379
1382
|
renderAndroidPermissions,
|
|
1380
1383
|
renderAndroidDeepLinks,
|
|
1381
1384
|
open,
|
|
1385
|
+
nativeRouteRuntime,
|
|
1382
1386
|
nativeRouteBundles,
|
|
1387
|
+
nativeMessages,
|
|
1383
1388
|
nativeMapProviders,
|
|
1384
1389
|
installRuntime,
|
|
1385
1390
|
init,
|
|
@@ -71,13 +71,46 @@ internal object CraftNativeBundle {
|
|
|
71
71
|
private fun read(context: Context, asset: String): String? = sources.get(asset)
|
|
72
72
|
?: runCatching { context.assets.open(asset).bufferedReader().use { it.readText() } }.getOrNull()?.also { sources.put(asset, it) }
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A route's file runs on the runtime stx writes beside it
|
|
76
|
+
* (`native-screens/__runtime.js`), so the two go to the isolate together,
|
|
77
|
+
* runtime first, then the route's messages. A one-file bundle carries its
|
|
78
|
+
* runtime itself.
|
|
79
|
+
*/
|
|
74
80
|
fun source(context: Context, route: String?): String? {
|
|
75
81
|
if (!route.isNullOrBlank() && !route.contains('/') && !route.startsWith('.')) {
|
|
76
|
-
read(context, "native-screens/$route.js")?.let {
|
|
82
|
+
read(context, "native-screens/$route.js")?.let { own ->
|
|
83
|
+
val runtime = read(context, "native-screens/__runtime.js") ?: return own
|
|
84
|
+
val messages = messages(context, route)
|
|
85
|
+
return runtime + "\n;\n" + (if (messages != null) messages + "\n;\n" else "") + own
|
|
86
|
+
}
|
|
77
87
|
}
|
|
78
88
|
return read(context, "native-screen.js")
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The messages of the areas a route uses (stx's `messages`:
|
|
93
|
+
* `native-screens/__messages/routes.json` names them), in every language.
|
|
94
|
+
* On iOS a screen reads one language's area the first time it looks
|
|
95
|
+
* inside it; an isolate cannot call back into the app and wait for an
|
|
96
|
+
* answer, so here they are evaluated ahead of the route, as
|
|
97
|
+
* `__stxNativeMessages`, which the runtime reads first.
|
|
98
|
+
*/
|
|
99
|
+
private fun messages(context: Context, route: String): String? {
|
|
100
|
+
val listing = read(context, "native-screens/__messages/routes.json") ?: return null
|
|
101
|
+
val areas = runCatching { org.json.JSONObject(listing).optJSONArray(route) }.getOrNull() ?: return null
|
|
102
|
+
if (areas.length() == 0) return null
|
|
103
|
+
val languages = context.assets.list("native-screens/__messages")?.filter { !it.contains('.') }?.sorted() ?: return null
|
|
104
|
+
val tables = languages.joinToString(",") { language ->
|
|
105
|
+
val entries = (0 until areas.length()).mapNotNull { index ->
|
|
106
|
+
val area = areas.optString(index)
|
|
107
|
+
read(context, "native-screens/__messages/$language/$area.json")?.let { "${org.json.JSONObject.quote(area)}:$it" }
|
|
108
|
+
}
|
|
109
|
+
"${org.json.JSONObject.quote(language)}:{${entries.joinToString(",")}}"
|
|
110
|
+
}
|
|
111
|
+
return "globalThis.__stxNativeMessages={$tables};"
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
fun isAvailable(context: Context): Boolean =
|
|
82
115
|
runCatching { context.assets.open("native-screen.js").close(); true }.getOrDefault(false)
|
|
83
116
|
|| (context.assets.list("native-screens")?.isNotEmpty() == true)
|
|
@@ -206,7 +239,8 @@ internal class CraftNativeHeadlessApp(
|
|
|
206
239
|
colorScheme: 'light',
|
|
207
240
|
appURL: ${JSONObject.quote(config.optString("devServerURL"))},
|
|
208
241
|
appVersion: '',
|
|
209
|
-
appBuild: ''
|
|
242
|
+
appBuild: '',
|
|
243
|
+
sessionTokenKey: ${JSONObject.quote(CraftNativePrelude.sessionTokenKey(config))}
|
|
210
244
|
""".trimIndent()
|
|
211
245
|
val prelude = CraftNativePrelude.script(
|
|
212
246
|
bridgeFields,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import android.os.VibrationEffect
|
|
9
|
+
import android.os.Vibrator
|
|
10
|
+
import android.os.VibratorManager
|
|
11
|
+
import android.view.HapticFeedbackConstants
|
|
12
|
+
import android.view.View
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A native screen's haptics, in the names iOS plays (CraftNativeActions.swift,
|
|
16
|
+
* `CraftHaptics.play`): an impact weight (`light`, `medium`, `heavy`, `soft`,
|
|
17
|
+
* `rigid`), a notification type (`success`, `warning`, `error`) or
|
|
18
|
+
* `selection`. Anything else plays a medium impact, as an unknown style does
|
|
19
|
+
* on iOS.
|
|
20
|
+
*
|
|
21
|
+
* The screen's own haptic feedback comes first. It needs no VIBRATE
|
|
22
|
+
* permission and follows the system's touch-feedback setting, the way UIKit's
|
|
23
|
+
* feedback generators follow System Haptics, so a person who turned haptics
|
|
24
|
+
* off feels nothing. A style with no feedback constant on the device's API
|
|
25
|
+
* level (a warning on every version, success and error before Android 11)
|
|
26
|
+
* goes to the vibrator: a predefined effect from Android 10, timings before.
|
|
27
|
+
*/
|
|
28
|
+
internal object CraftNativeHaptics {
|
|
29
|
+
val impactStyles = setOf("light", "medium", "heavy", "soft", "rigid")
|
|
30
|
+
val notificationTypes = setOf("success", "warning", "error")
|
|
31
|
+
|
|
32
|
+
// Lazy, so the pure mappings load in a JVM unit test with no main looper.
|
|
33
|
+
private val main by lazy { Handler(Looper.getMainLooper()) }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The style a `Haptics.<method>(args[0])` call plays, or null for a method
|
|
37
|
+
* that plays nothing (`prepare`; Android has no engine to warm). `impact`
|
|
38
|
+
* takes any style, as iOS's does: stx's bridge sends every kind through it.
|
|
39
|
+
*/
|
|
40
|
+
fun style(method: String, argument: String?): String? = when (method) {
|
|
41
|
+
"impact" -> argument?.takeIf { it.isNotBlank() } ?: "medium"
|
|
42
|
+
"notification" -> argument?.takeIf { it in notificationTypes } ?: "success"
|
|
43
|
+
"selection" -> "selection"
|
|
44
|
+
else -> null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The view feedback constant for `style` on API level `sdk`, or null when there is none. */
|
|
48
|
+
@SuppressLint("InlinedApi")
|
|
49
|
+
fun feedbackConstant(style: String, sdk: Int): Int? = when (style) {
|
|
50
|
+
"light" -> HapticFeedbackConstants.KEYBOARD_TAP
|
|
51
|
+
"medium" -> HapticFeedbackConstants.VIRTUAL_KEY
|
|
52
|
+
"heavy" -> HapticFeedbackConstants.LONG_PRESS
|
|
53
|
+
"soft" -> if (sdk >= 27) HapticFeedbackConstants.TEXT_HANDLE_MOVE else HapticFeedbackConstants.CLOCK_TICK
|
|
54
|
+
"rigid" -> HapticFeedbackConstants.CONTEXT_CLICK
|
|
55
|
+
"selection" -> if (sdk >= 34) HapticFeedbackConstants.SEGMENT_TICK else HapticFeedbackConstants.CLOCK_TICK
|
|
56
|
+
"success" -> if (sdk >= 30) HapticFeedbackConstants.CONFIRM else null
|
|
57
|
+
"error" -> if (sdk >= 30) HapticFeedbackConstants.REJECT else null
|
|
58
|
+
"warning" -> null
|
|
59
|
+
else -> HapticFeedbackConstants.VIRTUAL_KEY
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The predefined `VibrationEffect.EFFECT_*` for `style`, Android 10 and later. */
|
|
63
|
+
@SuppressLint("InlinedApi")
|
|
64
|
+
fun predefinedEffect(style: String): Int = when (style) {
|
|
65
|
+
"light", "soft", "selection" -> VibrationEffect.EFFECT_TICK
|
|
66
|
+
"heavy", "error" -> VibrationEffect.EFFECT_HEAVY_CLICK
|
|
67
|
+
"success", "warning" -> VibrationEffect.EFFECT_DOUBLE_CLICK
|
|
68
|
+
else -> VibrationEffect.EFFECT_CLICK
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Before Android 10: one duration for a one-shot, or a waveform
|
|
73
|
+
* `[wait, vibrate, wait, vibrate]`. The weights the WebView bridge and the
|
|
74
|
+
* Zig runtime already play (bridge_android_haptics.zig's effectForStyle).
|
|
75
|
+
*/
|
|
76
|
+
fun timings(style: String): LongArray = when (style) {
|
|
77
|
+
"light" -> longArrayOf(10)
|
|
78
|
+
"soft" -> longArrayOf(8)
|
|
79
|
+
"selection" -> longArrayOf(5)
|
|
80
|
+
"rigid" -> longArrayOf(25)
|
|
81
|
+
"heavy" -> longArrayOf(50)
|
|
82
|
+
"success" -> longArrayOf(0, 30, 50, 30)
|
|
83
|
+
"warning" -> longArrayOf(0, 50, 100, 50)
|
|
84
|
+
"error" -> longArrayOf(0, 100, 50, 100)
|
|
85
|
+
else -> longArrayOf(20)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Play `style`: true when the screen or the vibrator took it. False is
|
|
90
|
+
* "nothing played" (touch feedback off, no vibrator, no permission), never
|
|
91
|
+
* an error, because haptics decorate a flow rather than carry it.
|
|
92
|
+
*/
|
|
93
|
+
fun play(context: Context, view: View?, style: String): Boolean {
|
|
94
|
+
if (Looper.myLooper() != Looper.getMainLooper()) {
|
|
95
|
+
main.post { play(context, view, style) }
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
val constant = feedbackConstant(style, Build.VERSION.SDK_INT)
|
|
99
|
+
if (constant != null && view != null && view.isAttachedToWindow)
|
|
100
|
+
return view.performHapticFeedback(constant)
|
|
101
|
+
val vibrator = vibrator(context) ?: return false
|
|
102
|
+
if (!vibrator.hasVibrator()) return false
|
|
103
|
+
return runCatching {
|
|
104
|
+
vibrator.vibrate(effect(style))
|
|
105
|
+
true
|
|
106
|
+
}.getOrDefault(false)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun effect(style: String): VibrationEffect {
|
|
110
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return VibrationEffect.createPredefined(predefinedEffect(style))
|
|
111
|
+
val timings = timings(style)
|
|
112
|
+
return if (timings.size == 1) VibrationEffect.createOneShot(timings[0], VibrationEffect.DEFAULT_AMPLITUDE)
|
|
113
|
+
else VibrationEffect.createWaveform(timings, -1)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun vibrator(context: Context): Vibrator? = runCatching {
|
|
117
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
118
|
+
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
|
119
|
+
} else {
|
|
120
|
+
@Suppress("DEPRECATION")
|
|
121
|
+
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
|
122
|
+
}
|
|
123
|
+
}.getOrNull()
|
|
124
|
+
}
|