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.
@@ -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 { return it }
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
+ }