expo-invoke 1.0.0

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.
Files changed (144) hide show
  1. package/MIGRATION.md +125 -0
  2. package/README.md +942 -0
  3. package/SECURITY.md +35 -0
  4. package/android/build.gradle +42 -0
  5. package/android/src/main/java/expo/modules/invoke/AppActionsHandler.kt +74 -0
  6. package/android/src/main/java/expo/modules/invoke/ExpoInvokeModule.kt +135 -0
  7. package/android/src/main/java/expo/modules/invoke/QuickTileService.kt +63 -0
  8. package/android/src/main/java/expo/modules/invoke/ShortcutsHelper.kt +72 -0
  9. package/build/cli/commands/generate.d.ts +1 -0
  10. package/build/cli/commands/generate.js +119 -0
  11. package/build/cli/commands/generate.js.map +1 -0
  12. package/build/cli/commands/test.d.ts +1 -0
  13. package/build/cli/commands/test.js +53 -0
  14. package/build/cli/commands/test.js.map +1 -0
  15. package/build/cli/commands/validate.d.ts +1 -0
  16. package/build/cli/commands/validate.js +116 -0
  17. package/build/cli/commands/validate.js.map +1 -0
  18. package/build/cli/index.d.ts +2 -0
  19. package/build/cli/index.js +37 -0
  20. package/build/cli/index.js.map +1 -0
  21. package/build/plugin/android/withAndroidInvoke.d.ts +8 -0
  22. package/build/plugin/android/withAndroidInvoke.js +106 -0
  23. package/build/plugin/android/withAndroidInvoke.js.map +1 -0
  24. package/build/plugin/codegen/generateActionsXml.d.ts +2 -0
  25. package/build/plugin/codegen/generateActionsXml.js +69 -0
  26. package/build/plugin/codegen/generateActionsXml.js.map +1 -0
  27. package/build/plugin/codegen/generateShortcutsXml.d.ts +2 -0
  28. package/build/plugin/codegen/generateShortcutsXml.js +45 -0
  29. package/build/plugin/codegen/generateShortcutsXml.js.map +1 -0
  30. package/build/plugin/codegen/generateSwiftIntents.d.ts +2 -0
  31. package/build/plugin/codegen/generateSwiftIntents.js +129 -0
  32. package/build/plugin/codegen/generateSwiftIntents.js.map +1 -0
  33. package/build/plugin/ios/withIOSInvoke.d.ts +9 -0
  34. package/build/plugin/ios/withIOSInvoke.js +83 -0
  35. package/build/plugin/ios/withIOSInvoke.js.map +1 -0
  36. package/build/plugin/src/withInvoke.d.ts +16 -0
  37. package/build/plugin/src/withInvoke.js +13 -0
  38. package/build/plugin/src/withInvoke.js.map +1 -0
  39. package/build/src/components/AddToSiriButton.d.ts +18 -0
  40. package/build/src/components/AddToSiriButton.d.ts.map +1 -0
  41. package/build/src/components/AddToSiriButton.js +35 -0
  42. package/build/src/components/AddToSiriButton.js.map +1 -0
  43. package/build/src/components/InvokeDebugger.d.ts +12 -0
  44. package/build/src/components/InvokeDebugger.d.ts.map +1 -0
  45. package/build/src/components/InvokeDebugger.js +122 -0
  46. package/build/src/components/InvokeDebugger.js.map +1 -0
  47. package/build/src/components/ShortcutChip.d.ts +20 -0
  48. package/build/src/components/ShortcutChip.d.ts.map +1 -0
  49. package/build/src/components/ShortcutChip.js +44 -0
  50. package/build/src/components/ShortcutChip.js.map +1 -0
  51. package/build/src/hooks/useAddToSiri.d.ts +9 -0
  52. package/build/src/hooks/useAddToSiri.d.ts.map +1 -0
  53. package/build/src/hooks/useAddToSiri.js +15 -0
  54. package/build/src/hooks/useAddToSiri.js.map +1 -0
  55. package/build/src/hooks/useDonation.d.ts +26 -0
  56. package/build/src/hooks/useDonation.d.ts.map +1 -0
  57. package/build/src/hooks/useDonation.js +21 -0
  58. package/build/src/hooks/useDonation.js.map +1 -0
  59. package/build/src/hooks/useFocusFilter.d.ts +11 -0
  60. package/build/src/hooks/useFocusFilter.d.ts.map +1 -0
  61. package/build/src/hooks/useFocusFilter.js +24 -0
  62. package/build/src/hooks/useFocusFilter.js.map +1 -0
  63. package/build/src/hooks/useHandoff.d.ts +29 -0
  64. package/build/src/hooks/useHandoff.d.ts.map +1 -0
  65. package/build/src/hooks/useHandoff.js +27 -0
  66. package/build/src/hooks/useHandoff.js.map +1 -0
  67. package/build/src/hooks/useInvoke.d.ts +43 -0
  68. package/build/src/hooks/useInvoke.d.ts.map +1 -0
  69. package/build/src/hooks/useInvoke.js +70 -0
  70. package/build/src/hooks/useInvoke.js.map +1 -0
  71. package/build/src/hooks/useInvokeAnalytics.d.ts +23 -0
  72. package/build/src/hooks/useInvokeAnalytics.d.ts.map +1 -0
  73. package/build/src/hooks/useInvokeAnalytics.js +27 -0
  74. package/build/src/hooks/useInvokeAnalytics.js.map +1 -0
  75. package/build/src/hooks/useInvokeHistory.d.ts +11 -0
  76. package/build/src/hooks/useInvokeHistory.d.ts.map +1 -0
  77. package/build/src/hooks/useInvokeHistory.js +26 -0
  78. package/build/src/hooks/useInvokeHistory.js.map +1 -0
  79. package/build/src/hooks/useInvokeInit.d.ts +16 -0
  80. package/build/src/hooks/useInvokeInit.d.ts.map +1 -0
  81. package/build/src/hooks/useInvokeInit.js +32 -0
  82. package/build/src/hooks/useInvokeInit.js.map +1 -0
  83. package/build/src/hooks/useLiveActivityIntent.d.ts +21 -0
  84. package/build/src/hooks/useLiveActivityIntent.d.ts.map +1 -0
  85. package/build/src/hooks/useLiveActivityIntent.js +9 -0
  86. package/build/src/hooks/useLiveActivityIntent.js.map +1 -0
  87. package/build/src/hooks/useQuickTile.d.ts +23 -0
  88. package/build/src/hooks/useQuickTile.d.ts.map +1 -0
  89. package/build/src/hooks/useQuickTile.js +15 -0
  90. package/build/src/hooks/useQuickTile.js.map +1 -0
  91. package/build/src/hooks/useShortcuts.d.ts +25 -0
  92. package/build/src/hooks/useShortcuts.d.ts.map +1 -0
  93. package/build/src/hooks/useShortcuts.js +23 -0
  94. package/build/src/hooks/useShortcuts.js.map +1 -0
  95. package/build/src/hooks/useSpotlight.d.ts +23 -0
  96. package/build/src/hooks/useSpotlight.d.ts.map +1 -0
  97. package/build/src/hooks/useSpotlight.js +13 -0
  98. package/build/src/hooks/useSpotlight.js.map +1 -0
  99. package/build/src/hooks/useWidgetData.d.ts +20 -0
  100. package/build/src/hooks/useWidgetData.d.ts.map +1 -0
  101. package/build/src/hooks/useWidgetData.js +12 -0
  102. package/build/src/hooks/useWidgetData.js.map +1 -0
  103. package/build/src/index.d.ts +32 -0
  104. package/build/src/index.d.ts.map +1 -0
  105. package/build/src/index.js +27 -0
  106. package/build/src/index.js.map +1 -0
  107. package/build/src/module.d.ts +25 -0
  108. package/build/src/module.d.ts.map +1 -0
  109. package/build/src/module.js +102 -0
  110. package/build/src/module.js.map +1 -0
  111. package/build/src/types/index.d.ts +156 -0
  112. package/build/src/types/index.d.ts.map +1 -0
  113. package/build/src/types/index.js +4 -0
  114. package/build/src/types/index.js.map +1 -0
  115. package/build/src/utils/actionStore.d.ts +6 -0
  116. package/build/src/utils/actionStore.d.ts.map +1 -0
  117. package/build/src/utils/actionStore.js +32 -0
  118. package/build/src/utils/actionStore.js.map +1 -0
  119. package/build/src/utils/analytics.d.ts +12 -0
  120. package/build/src/utils/analytics.d.ts.map +1 -0
  121. package/build/src/utils/analytics.js +29 -0
  122. package/build/src/utils/analytics.js.map +1 -0
  123. package/build/src/utils/testing.d.ts +39 -0
  124. package/build/src/utils/testing.d.ts.map +1 -0
  125. package/build/src/utils/testing.js +58 -0
  126. package/build/src/utils/testing.js.map +1 -0
  127. package/build/src/utils/validation.d.ts +11 -0
  128. package/build/src/utils/validation.d.ts.map +1 -0
  129. package/build/src/utils/validation.js +128 -0
  130. package/build/src/utils/validation.js.map +1 -0
  131. package/expo-module.config.json +9 -0
  132. package/ios/Focus/FocusFilterManager.swift +19 -0
  133. package/ios/Intents/BaseInvokeIntent.swift +42 -0
  134. package/ios/Intents/HandoffManager.swift +49 -0
  135. package/ios/Intents/InvokeActionStore.swift +21 -0
  136. package/ios/Intents/InvokeParameterTypes.swift +142 -0
  137. package/ios/LiveActivity/LiveActivityIntentBridge.swift +44 -0
  138. package/ios/Module/ExpoInvokeModule.swift +170 -0
  139. package/ios/Notifications/NotificationActionHandler.swift +80 -0
  140. package/ios/Shortcuts/DonationManager.swift +44 -0
  141. package/ios/Shortcuts/ShortcutsManager.swift +32 -0
  142. package/ios/Shortcuts/SpotlightManager.swift +48 -0
  143. package/ios/Widget/WidgetManager.swift +19 -0
  144. package/package.json +111 -0
package/README.md ADDED
@@ -0,0 +1,942 @@
1
+ # expo-invoke
2
+
3
+ The complete native-surface-to-JS bridge for Expo. Define one intent in `app.json` and it automatically works from **every surface** — Siri, Google Assistant, home screen widgets, Dynamic Island, app icon long-press menus, notification action buttons, NFC tags, QR codes, deep links, and more — all through a single `useInvoke()` hook.
4
+
5
+ **No manual Swift or Kotlin required.** The config plugin generates all native code from your `app.json`.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [Requirements](#requirements)
12
+ - [Installation](#installation)
13
+ - [Quick Start](#quick-start)
14
+ - [useInvoke() — Full API](#useinvoke--full-api)
15
+ - [Hooks](#hooks)
16
+ - [useInvokeInit](#useinvokeinit)
17
+ - [useInvoke](#useinvoke)
18
+ - [useDonation — Siri Suggestions](#usedonation--siri-suggestions)
19
+ - [useShortcuts — Launcher Long-Press Menu](#useshortcuts--launcher-long-press-menu)
20
+ - [useWidgetData — Push State to Widgets](#usewidgetdata--push-state-to-widgets)
21
+ - [useSpotlight — iOS Spotlight Search](#usespotlight--ios-spotlight-search)
22
+ - [useFocusFilter — iOS Focus Mode](#usefocusfilter--ios-focus-mode)
23
+ - [useAddToSiri](#useaddtosiri)
24
+ - [useQuickTile — Android Quick Settings](#usequicktile--android-quick-settings)
25
+ - [useHandoff — Apple Handoff](#usehandoff--apple-handoff)
26
+ - [useLiveActivityIntent — Dynamic Island](#useliveactivityintent--dynamic-island)
27
+ - [useInvokeHistory](#useinvokehistory)
28
+ - [useInvokeAnalytics](#useinvokeanalytics)
29
+ - [Components](#components)
30
+ - [Parameter Types](#parameter-types)
31
+ - [app.json Config Reference](#appjson-config-reference)
32
+ - [intents](#intents)
33
+ - [widgets](#widgets)
34
+ - [notificationActions](#notificationactions)
35
+ - [liveActivityIntents](#liveactivityintents)
36
+ - [Plugin options](#plugin-options)
37
+ - [Intent TTL](#intent-ttl)
38
+ - [CLI](#cli)
39
+ - [TypeScript Codegen](#typescript-codegen)
40
+ - [Testing](#testing)
41
+ - [Supported Surfaces](#supported-surfaces)
42
+
43
+ ---
44
+
45
+ ## Requirements
46
+
47
+ | Platform | Minimum |
48
+ |---|---|
49
+ | iOS | 16.0 (App Intents API) |
50
+ | Android | API 24 / Android 7.0 |
51
+ | Expo SDK | 50+ |
52
+ | expo-modules-core | 1.12+ |
53
+
54
+ ---
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ npx expo install expo-invoke
60
+ ```
61
+
62
+ Then rebuild your native app:
63
+
64
+ ```bash
65
+ npx expo prebuild
66
+ npx expo run:ios
67
+ npx expo run:android
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Quick Start
73
+
74
+ ### 1. Configure your intents in `app.json`
75
+
76
+ ```json
77
+ {
78
+ "plugins": [
79
+ ["expo-invoke", {
80
+ "intents": [
81
+ {
82
+ "id": "create_errand",
83
+ "title": "Create an Errand",
84
+ "phrases": ["Create an errand in ${applicationName}"],
85
+ "shortTitle": "Create Errand",
86
+ "icon": "shippingbox"
87
+ }
88
+ ]
89
+ }]
90
+ ]
91
+ }
92
+ ```
93
+
94
+ > `${applicationName}` is required in every Siri phrase so the system knows which app to launch.
95
+
96
+ ### 2. Initialize in your root layout
97
+
98
+ ```tsx
99
+ // app/_layout.tsx
100
+ import { useInvokeInit } from 'expo-invoke';
101
+
102
+ export default function RootLayout() {
103
+ useInvokeInit(); // handles cold start AND warm start — call once, here only
104
+ return <Stack />;
105
+ }
106
+ ```
107
+
108
+ ### 3. React to intents anywhere in your app
109
+
110
+ ```tsx
111
+ import { useEffect } from 'react';
112
+ import { useInvoke } from 'expo-invoke';
113
+
114
+ export default function HomeScreen() {
115
+ const { intent, clear } = useInvoke({ intentId: 'create_errand' });
116
+
117
+ useEffect(() => {
118
+ if (!intent) return;
119
+ router.push('/create-errand');
120
+ clear();
121
+ }, [intent]);
122
+
123
+ return <View />;
124
+ }
125
+ ```
126
+
127
+ ---
128
+
129
+ ## useInvoke() — Full API
130
+
131
+ ```tsx
132
+ const { intent, parameters, source, clear } = useInvoke({
133
+ // Filter to a specific intent (optional)
134
+ intentId: 'create_errand',
135
+
136
+ // Filter to specific surfaces (optional — defaults to all)
137
+ source: ['siri', 'widget', 'nfc'],
138
+
139
+ // Inline handler (optional — alternative to watching the returned value)
140
+ onFire: (intent) => {
141
+ console.log(intent.intentId, intent.source, intent.parameters);
142
+ },
143
+ });
144
+
145
+ // intent — ResolvedIntent | null
146
+ // parameters — Record<string, ParameterValue> (shorthand for intent?.parameters)
147
+ // source — InvokeSource | undefined
148
+ // clear() — clears the pending intent so it doesn't re-fire on re-render
149
+ ```
150
+
151
+ **`ResolvedIntent` shape:**
152
+
153
+ ```ts
154
+ {
155
+ intentId: string; // e.g. "create_errand"
156
+ source: InvokeSource; // e.g. "siri"
157
+ timestamp: number; // ms since epoch
158
+ parameters: Record<string, ParameterValue>;
159
+ rawUrl?: string; // present when source is 'deep_link' | 'nfc' | 'qr'
160
+ }
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Hooks
166
+
167
+ ### useInvokeInit
168
+
169
+ Call **once** in your root layout. Handles two start paths:
170
+
171
+ - **Cold start** — app was not running; Siri/widget wrote the intent to native storage before JS loaded. `useInvokeInit` reads it on mount.
172
+ - **Warm start** — app was backgrounded; intent fires as a native event. `useInvokeInit` subscribes and routes it to `useInvoke`.
173
+
174
+ ```tsx
175
+ // app/_layout.tsx
176
+ import { useInvokeInit } from 'expo-invoke';
177
+
178
+ export default function RootLayout() {
179
+ useInvokeInit();
180
+ return <Stack />;
181
+ }
182
+ ```
183
+
184
+ ---
185
+
186
+ ### useInvoke
187
+
188
+ The universal receiver. Works the same regardless of which surface fired the intent.
189
+
190
+ ```tsx
191
+ // No filter — receive any intent from any surface
192
+ const { intent, clear } = useInvoke();
193
+
194
+ // Filter by intentId
195
+ const { intent } = useInvoke({ intentId: 'track_errand' });
196
+
197
+ // Filter by source
198
+ const { intent } = useInvoke({ source: ['widget', 'notification'] });
199
+
200
+ // Inline handler
201
+ useInvoke({
202
+ intentId: 'create_errand',
203
+ onFire: ({ parameters }) => {
204
+ createErrand({ pickup: parameters.pickup });
205
+ },
206
+ });
207
+ ```
208
+
209
+ ---
210
+
211
+ ### useDonation — Siri Suggestions
212
+
213
+ Donating an action tells Siri the user performed it. After several donations, Siri surfaces the phrase as a suggestion on the lock screen, in Spotlight, and in the Siri watch face.
214
+
215
+ ```tsx
216
+ import { useDonation } from 'expo-invoke';
217
+
218
+ function CreateErrandScreen() {
219
+ const { donate, remove, removeAll } = useDonation();
220
+
221
+ const handleCreate = async (errand) => {
222
+ await createErrand(errand);
223
+
224
+ // Donate after a successful action — Siri learns the pattern
225
+ donate({
226
+ intentId: 'create_errand',
227
+ title: `Create errand to ${errand.destination}`,
228
+ parameters: { pickup: errand.pickup },
229
+ });
230
+ };
231
+
232
+ // Remove a specific donation (e.g. when errand is cancelled)
233
+ const handleCancel = () => remove('create_errand');
234
+
235
+ // Remove all donations for a fresh start
236
+ const handleReset = () => removeAll();
237
+ }
238
+ ```
239
+
240
+ > Donations accumulate over time. The more a user performs an action the higher Siri ranks it.
241
+
242
+ ---
243
+
244
+ ### useShortcuts — Launcher Long-Press Menu
245
+
246
+ Dynamic shortcuts appear when the user long-presses your app icon. They update in real time based on what the user does most.
247
+
248
+ ```tsx
249
+ import { useShortcuts } from 'expo-invoke';
250
+
251
+ function App() {
252
+ const { setShortcuts, clear } = useShortcuts();
253
+
254
+ useEffect(() => {
255
+ // Called after login — show personalized shortcuts
256
+ setShortcuts([
257
+ {
258
+ id: 'recent_errand',
259
+ title: 'Resume Last Errand',
260
+ subtitle: 'Pick up from Accra Mall',
261
+ icon: 'arrow.clockwise',
262
+ intentId: 'track_errand',
263
+ parameters: { errandId: '123' },
264
+ },
265
+ {
266
+ id: 'new_errand',
267
+ title: 'Create Errand',
268
+ icon: 'plus',
269
+ intentId: 'create_errand',
270
+ },
271
+ ]);
272
+ }, [userId]);
273
+ }
274
+ ```
275
+
276
+ Static shortcuts (always visible, defined at install time) are configured via the `shortTitle` field in `app.json` and generated automatically by the config plugin.
277
+
278
+ ---
279
+
280
+ ### useWidgetData — Push State to Widgets
281
+
282
+ Push live app state to home screen widgets without the user opening the app. The widget reads from the same shared storage.
283
+
284
+ ```tsx
285
+ import { useWidgetData } from 'expo-invoke';
286
+
287
+ function TrackingScreen({ errand }) {
288
+ const { set } = useWidgetData('active_errand');
289
+
290
+ useEffect(() => {
291
+ // Widget reads this to display current status
292
+ set({
293
+ title: errand.title,
294
+ status: errand.status,
295
+ eta: errand.estimatedArrival,
296
+ });
297
+ }, [errand]);
298
+ }
299
+ ```
300
+
301
+ On iOS, data is stored in an App Group shared `UserDefaults` container. Configure the App Group in `app.json`:
302
+
303
+ ```json
304
+ ["expo-invoke", { "appGroupId": "group.com.myapp.invoke" }]
305
+ ```
306
+
307
+ ---
308
+
309
+ ### useSpotlight — iOS Spotlight Search
310
+
311
+ Index items so users can find them via iOS Spotlight (swipe down on home screen). Tapping a result fires the associated intent.
312
+
313
+ ```tsx
314
+ import { useSpotlight } from 'expo-invoke';
315
+
316
+ function ErrandsListScreen({ errands }) {
317
+ const { index, deindex } = useSpotlight();
318
+
319
+ useEffect(() => {
320
+ index(
321
+ errands.map((e) => ({
322
+ id: `errand-${e.id}`,
323
+ title: e.title,
324
+ description: e.description,
325
+ keywords: ['errand', 'delivery', e.destination],
326
+ intentId: 'track_errand',
327
+ intentParameters: { errandId: e.id },
328
+ }))
329
+ );
330
+
331
+ return () => deindex(errands.map((e) => `errand-${e.id}`));
332
+ }, [errands]);
333
+ }
334
+ ```
335
+
336
+ ---
337
+
338
+ ### useFocusFilter — iOS Focus Mode
339
+
340
+ Read the active iOS Focus mode (Work, Personal, Sleep, etc.) and adapt your UI or behaviour accordingly.
341
+
342
+ ```tsx
343
+ import { useFocusFilter } from 'expo-invoke';
344
+
345
+ function NotificationSettings() {
346
+ const focus = useFocusFilter();
347
+ // focus: 'work' | 'personal' | 'sleep' | 'do_not_disturb' | 'driving' | null
348
+
349
+ return (
350
+ <View>
351
+ {focus === 'work' && <Text>Work mode — showing only work shortcuts</Text>}
352
+ {focus === 'sleep' && <Text>Sleep mode — notifications silenced</Text>}
353
+ {focus === null && <Text>No active focus</Text>}
354
+ </View>
355
+ );
356
+ }
357
+ ```
358
+
359
+ Re-reads automatically every time the app comes to the foreground.
360
+
361
+ ---
362
+
363
+ ### useAddToSiri
364
+
365
+ Programmatically open the native "Add to Siri" sheet so users can record a custom voice phrase for any intent.
366
+
367
+ ```tsx
368
+ import { useAddToSiri } from 'expo-invoke';
369
+ // or use the pre-built <AddToSiriButton /> component
370
+
371
+ function IntentSettings() {
372
+ const addToSiri = useAddToSiri('create_errand');
373
+
374
+ return (
375
+ <TouchableOpacity onPress={addToSiri}>
376
+ <Text>Add "Create Errand" to Siri</Text>
377
+ </TouchableOpacity>
378
+ );
379
+ }
380
+ ```
381
+
382
+ ---
383
+
384
+ ### useQuickTile — Android Quick Settings
385
+
386
+ Show a tile in the Android Quick Settings panel (swipe down twice). The tile can be tapped even when the app is closed.
387
+
388
+ ```tsx
389
+ import { useQuickTile } from 'expo-invoke';
390
+
391
+ function TrackingScreen({ isTracking }) {
392
+ const { update } = useQuickTile();
393
+
394
+ useEffect(() => {
395
+ update(
396
+ isTracking ? 'active' : 'inactive',
397
+ isTracking ? 'Tracking errand' : 'Start tracking'
398
+ );
399
+ }, [isTracking]);
400
+ }
401
+ ```
402
+
403
+ States: `'active'` (tinted) · `'inactive'` (dimmed) · `'unavailable'` (greyed out, not tappable)
404
+
405
+ The tile maps to the `quick_tile` intent source — configure which intent the tile fires in `app.json` with `"supportsQuickTile": true` on the intent.
406
+
407
+ ---
408
+
409
+ ### useHandoff — Apple Handoff
410
+
411
+ Let users continue an in-progress action on another Apple device (iPhone → iPad → Mac).
412
+
413
+ ```tsx
414
+ import { useHandoff } from 'expo-invoke';
415
+
416
+ function CreateErrandScreen({ draft }) {
417
+ const { advertise, stop, received } = useHandoff();
418
+
419
+ useEffect(() => {
420
+ // Broadcast: "I'm creating an errand, any device can continue"
421
+ advertise('create_errand', { draft: JSON.stringify(draft) });
422
+ return () => stop();
423
+ }, [draft]);
424
+
425
+ useEffect(() => {
426
+ if (!received) return;
427
+ // Another device handed off to this one — restore the draft
428
+ const restoredDraft = JSON.parse(received.parameters.draft as string);
429
+ setDraft(restoredDraft);
430
+ }, [received]);
431
+ }
432
+ ```
433
+
434
+ ---
435
+
436
+ ### useLiveActivityIntent — Dynamic Island
437
+
438
+ Link a Live Activity to an intent so tapping the Dynamic Island compact view or Lock Screen banner fires that intent.
439
+
440
+ ```tsx
441
+ import { useLiveActivityIntent } from 'expo-invoke';
442
+
443
+ function ErrandTrackingScreen({ activityId }) {
444
+ const { set } = useLiveActivityIntent(activityId);
445
+
446
+ useEffect(() => {
447
+ // Tapping the Dynamic Island fires track_errand
448
+ set('track_errand');
449
+ }, [activityId]);
450
+ }
451
+ ```
452
+
453
+ Received via `useInvoke({ source: ['live_activity'] })`.
454
+
455
+ Configure at the plugin level for static linking:
456
+
457
+ ```json
458
+ ["expo-invoke", {
459
+ "liveActivityIntents": {
460
+ "errandInProgress": "track_errand"
461
+ }
462
+ }]
463
+ ```
464
+
465
+ ---
466
+
467
+ ### useInvokeHistory
468
+
469
+ Access the last N intents that fired (persisted in memory for the session).
470
+
471
+ ```tsx
472
+ import { useInvokeHistory } from 'expo-invoke';
473
+
474
+ function RecentActionsScreen() {
475
+ const history = useInvokeHistory(10); // last 10
476
+
477
+ return (
478
+ <FlatList
479
+ data={history}
480
+ renderItem={({ item }) => (
481
+ <Text>{item.intentId} via {item.source}</Text>
482
+ )}
483
+ />
484
+ );
485
+ }
486
+ ```
487
+
488
+ Returns `ResolvedIntent[]` in reverse-chronological order (most recent first).
489
+
490
+ ---
491
+
492
+ ### useInvokeAnalytics
493
+
494
+ Track how often intents fire and from which surfaces — useful for optimising which shortcuts to promote.
495
+
496
+ ```tsx
497
+ import { useInvokeAnalytics } from 'expo-invoke';
498
+
499
+ function AnalyticsDashboard() {
500
+ const { fired, donated } = useInvokeAnalytics();
501
+ // fired: { create_errand: 42, track_errand: 17 }
502
+ // donated: { create_errand: 8 }
503
+
504
+ return (
505
+ <View>
506
+ {Object.entries(fired).map(([id, count]) => (
507
+ <Text key={id}>{id}: {count} fires</Text>
508
+ ))}
509
+ </View>
510
+ );
511
+ }
512
+ ```
513
+
514
+ Tap into the global analytics bus to forward events to Segment, Amplitude, etc.:
515
+
516
+ ```tsx
517
+ import { onIntentFired } from 'expo-invoke';
518
+
519
+ // In your analytics bootstrap (once, at app start)
520
+ onIntentFired((intent) => {
521
+ analytics.track('Intent Fired', {
522
+ intentId: intent.intentId,
523
+ source: intent.source,
524
+ });
525
+ });
526
+ ```
527
+
528
+ ---
529
+
530
+ ## Components
531
+
532
+ ### `<AddToSiriButton />`
533
+
534
+ Renders the native "Add to Siri" button on iOS. No-op on Android.
535
+
536
+ ```tsx
537
+ import { AddToSiriButton } from 'expo-invoke';
538
+
539
+ <AddToSiriButton
540
+ intentId="create_errand"
541
+ onPress={() => console.log('Sheet opened')}
542
+ style={{ alignSelf: 'center', marginTop: 16 }}
543
+ />
544
+ ```
545
+
546
+ ### `<ShortcutChip />`
547
+
548
+ A tappable chip representing a dynamic shortcut. Pressing it promotes the shortcut to the top of the launcher long-press list.
549
+
550
+ ```tsx
551
+ import { ShortcutChip } from 'expo-invoke';
552
+
553
+ <ShortcutChip
554
+ shortcutId="quick_errand"
555
+ label="Quick Errand"
556
+ icon="📦"
557
+ onPress={() => router.push('/create-errand')}
558
+ />
559
+ ```
560
+
561
+ ### `<InvokeDebugger />`
562
+
563
+ A dev-only floating overlay that shows the current pending intent, its source, and the last 5 intents from history. Renders `null` in production automatically.
564
+
565
+ ```tsx
566
+ // app/_layout.tsx — add it once, forget it
567
+ import { InvokeDebugger } from 'expo-invoke';
568
+
569
+ export default function RootLayout() {
570
+ useInvokeInit();
571
+ return (
572
+ <>
573
+ <Stack />
574
+ <InvokeDebugger />
575
+ </>
576
+ );
577
+ }
578
+ ```
579
+
580
+ ---
581
+
582
+ ## Parameter Types
583
+
584
+ The 8 supported parameter types and the JavaScript value shape you receive in `intent.parameters`:
585
+
586
+ | Type | Config | JS value |
587
+ |---|---|---|
588
+ | `text` | `{ "type": "text" }` | `string` |
589
+ | `number` | `{ "type": "number", "min": 1, "max": 100 }` | `number` |
590
+ | `boolean` | `{ "type": "boolean" }` | `boolean` |
591
+ | `date` | `{ "type": "date" }` | `number` (ms timestamp) |
592
+ | `location` | `{ "type": "location" }` | `{ latitude: number, longitude: number, name?: string }` |
593
+ | `person` | `{ "type": "person" }` | `{ displayName?: string, phoneNumber?: string, emailAddress?: string, contactIdentifier?: string }` |
594
+ | `enum` | `{ "type": "enum", "options": [{ "id": "...", "title": "..." }] }` | `string` (the selected option `id`) |
595
+ | `array` | `{ "type": "array", "itemType": "text" \| "number" \| "location" }` | `string[]`, `number[]`, or location object `[]` |
596
+
597
+ **Example: reading typed parameters**
598
+
599
+ ```tsx
600
+ useInvoke({
601
+ intentId: 'create_errand',
602
+ onFire: ({ parameters }) => {
603
+ const pickup = parameters.pickup as { latitude: number; longitude: number; name?: string };
604
+ const type = parameters.type as string; // enum id e.g. "delivery"
605
+ const quantity = parameters.quantity as number;
606
+ },
607
+ });
608
+ ```
609
+
610
+ > Use `npx expo-invoke generate` to generate a typed `invoke-types.d.ts` so TypeScript knows the exact shape of every intent's parameters.
611
+
612
+ ---
613
+
614
+ ## app.json Config Reference
615
+
616
+ ### intents
617
+
618
+ Each intent becomes a Siri phrase, an Android App Action, an app icon shortcut, and a widget button — all from one config entry.
619
+
620
+ ```json
621
+ {
622
+ "id": "create_errand",
623
+ "title": "Create an Errand",
624
+ "description": "Quickly create a new delivery errand",
625
+ "phrases": [
626
+ "Create an errand in ${applicationName}",
627
+ "New delivery in ${applicationName}"
628
+ ],
629
+ "shortTitle": "Create Errand",
630
+ "icon": "shippingbox",
631
+ "requiresConfirmation": false,
632
+ "spokenResponse": "Opening ${applicationName} to create your errand",
633
+ "openAppWhenRun": true,
634
+ "supportsCarPlay": false,
635
+ "supportsWatch": false,
636
+ "supportsControlCenter": false,
637
+ "supportsQuickTile": false,
638
+ "deepLinkPatterns": ["myapp://errands/create"],
639
+ "ttl": 30,
640
+ "parameters": [
641
+ { "name": "pickup", "type": "location", "title": "Pickup location", "optional": true },
642
+ { "name": "type", "type": "enum", "title": "Errand type",
643
+ "options": [{ "id": "delivery", "title": "Delivery" }, { "id": "pickup", "title": "Pickup" }] },
644
+ { "name": "notes", "type": "text", "title": "Notes", "optional": true }
645
+ ]
646
+ }
647
+ ```
648
+
649
+ | Field | Type | Description |
650
+ |---|---|---|
651
+ | `id` | `string` | Stable unique identifier. Never rename after shipping. |
652
+ | `title` | `string` | Shown in Siri and system UI. |
653
+ | `description` | `string?` | Subtitle in Shortcuts.app. |
654
+ | `phrases` | `string[]` | Siri voice phrases. Must include `${applicationName}`. |
655
+ | `shortTitle` | `string?` | Short label for app icon menus and widget buttons (≤ 10 chars for Android). |
656
+ | `icon` | `string?` | SF Symbol name (iOS) or Material icon name (Android). |
657
+ | `requiresConfirmation` | `boolean?` | Show a Siri confirmation dialog before running (default: `false`). |
658
+ | `spokenResponse` | `string?` | Text Siri reads aloud after the intent runs. Supports `${paramName}` substitution. |
659
+ | `openAppWhenRun` | `boolean?` | Whether to bring the app to foreground (default: `true`). |
660
+ | `supportsCarPlay` | `boolean?` | Enable in CarPlay. Phrase works automatically in CarPlay when `true`. |
661
+ | `supportsWatch` | `boolean?` | Enable companion button on watchOS / Wear OS. |
662
+ | `supportsControlCenter` | `boolean?` | iOS 18+ Control Center control. |
663
+ | `supportsQuickTile` | `boolean?` | Android Quick Settings tile. |
664
+ | `deepLinkPatterns` | `string[]?` | URL patterns that fire this intent (e.g. `"myapp://errands/create"`). |
665
+ | `ttl` | `number?` | Seconds before a stored intent expires (default: `30`). |
666
+ | `parameters` | `IntentParameter[]?` | Typed parameters Siri / Google can collect. |
667
+
668
+ ### widgets
669
+
670
+ Define home screen widgets. The config plugin generates the native WidgetKit extension (iOS) and AppWidgetProvider (Android) automatically.
671
+
672
+ ```json
673
+ "widgets": [
674
+ {
675
+ "id": "quick_errand",
676
+ "title": "Quick Errand",
677
+ "description": "One-tap errand creation",
678
+ "sizes": ["small", "medium"],
679
+ "backgroundColor": "#1C1C1E",
680
+ "actions": [
681
+ { "label": "Create", "intentId": "create_errand", "icon": "plus" },
682
+ { "label": "Track", "intentId": "track_errand", "icon": "location" }
683
+ ]
684
+ }
685
+ ]
686
+ ```
687
+
688
+ Widget button taps fire the intent with `source: 'widget'`. Use `useWidgetData()` to push live data into the widget from the app.
689
+
690
+ **Supported sizes:** `small` · `medium` · `large` · `extraLarge` · `accessory` (iOS lock screen)
691
+
692
+ ### notificationActions
693
+
694
+ Wire notification action buttons to intents. The config plugin registers the `UNNotificationCategory` (iOS) and `NotificationCompat.Action` (Android) automatically.
695
+
696
+ ```json
697
+ "notificationActions": [
698
+ {
699
+ "categoryId": "ERRAND_UPDATE",
700
+ "tapIntent": "track_errand",
701
+ "actions": [
702
+ { "id": "track", "title": "Track", "intentId": "track_errand" },
703
+ { "id": "cancel", "title": "Cancel", "intentId": "cancel_errand", "destructive": true },
704
+ { "id": "confirm", "title": "Confirm", "intentId": "confirm_errand", "foreground": true }
705
+ ]
706
+ }
707
+ ]
708
+ ```
709
+
710
+ - `tapIntent` — intent fired when the user taps the notification *body* (not a button). Received as `source: 'notification_tap'`.
711
+ - `destructive: true` — renders the button in red on iOS.
712
+ - `foreground: true` — brings the app to foreground when tapped.
713
+
714
+ When sending a notification from your server, include the category ID:
715
+
716
+ ```json
717
+ { "aps": { "category": "ERRAND_UPDATE", "alert": "Your errand has an update" } }
718
+ ```
719
+
720
+ ### liveActivityIntents
721
+
722
+ Map Live Activity names to intents. Tapping the Dynamic Island or Lock Screen banner fires the mapped intent.
723
+
724
+ ```json
725
+ "liveActivityIntents": {
726
+ "errandInProgress": "track_errand",
727
+ "paymentPending": "confirm_payment"
728
+ }
729
+ ```
730
+
731
+ Received as `source: 'live_activity'`. Override dynamically with `useLiveActivityIntent()`.
732
+
733
+ ### Plugin options
734
+
735
+ ```json
736
+ ["expo-invoke", {
737
+ "intents": [...],
738
+ "widgets": [...],
739
+ "notificationActions": [...],
740
+ "liveActivityIntents": { ... },
741
+ "voiceUsageDescription": "Use voice commands to create and track errands.",
742
+ "appGroupId": "group.com.myapp.invoke"
743
+ }]
744
+ ```
745
+
746
+ | Option | Description |
747
+ |---|---|
748
+ | `voiceUsageDescription` | iOS `NSSiriUsageDescription` / `NSVoiceUsageDescription` in Info.plist. |
749
+ | `appGroupId` | iOS App Group for widget data sharing via `useWidgetData()`. |
750
+
751
+ ---
752
+
753
+ ## Intent TTL
754
+
755
+ When an intent fires while the app is closed, it is written to native storage before JS loads. `useInvokeInit()` reads it on mount.
756
+
757
+ The default TTL is **30 seconds**. If the app takes longer than 30s to fully load and call `useInvokeInit()`, the intent is discarded. You can override per intent:
758
+
759
+ ```json
760
+ { "id": "create_errand", "ttl": 60, ... }
761
+ ```
762
+
763
+ This prevents a stale intent from firing long after the user has moved on. The TTL countdown starts from when the intent was stored natively (before the app launched), not from when JS mounts.
764
+
765
+ ---
766
+
767
+ ## CLI
768
+
769
+ ```bash
770
+ # Validate your app.json expo-invoke config
771
+ npx expo-invoke validate
772
+
773
+ # Generate TypeScript types from your config
774
+ npx expo-invoke generate
775
+
776
+ # Run the expo-invoke test suite
777
+ npx expo-invoke test
778
+ ```
779
+
780
+ ### validate
781
+
782
+ Catches:
783
+ - Missing `${applicationName}` in phrases
784
+ - Phrases longer than 140 characters
785
+ - Conflicting phrases across intents
786
+ - Duplicate intent or parameter IDs
787
+ - Invalid identifier format
788
+ - iOS shortcut count > 10 (warning)
789
+ - Android shortcut count > 5 (warning)
790
+
791
+ ```bash
792
+ $ npx expo-invoke validate
793
+ Validating expo-invoke config in /my-app ...
794
+
795
+ ⚠️ 1 warning(s):
796
+ • [intents] 11 intents with shortTitle — Android allows max 5 launcher shortcuts.
797
+
798
+ ✅ Configuration is valid.
799
+ ```
800
+
801
+ ### generate
802
+
803
+ Outputs `invoke-types.d.ts` in your project root with per-intent parameter types (see [TypeScript Codegen](#typescript-codegen)).
804
+
805
+ ---
806
+
807
+ ## TypeScript Codegen
808
+
809
+ Run `npx expo-invoke generate` after editing your `app.json`. It produces `invoke-types.d.ts`:
810
+
811
+ ```ts
812
+ // invoke-types.d.ts (auto-generated)
813
+ import type { ResolvedIntent } from "expo-invoke";
814
+
815
+ export type InvokeIntentId = "create_errand" | "track_errand" | "cancel_errand";
816
+
817
+ export interface CreateErrandIntent extends ResolvedIntent {
818
+ intentId: "create_errand";
819
+ parameters: {
820
+ pickup?: { latitude: number; longitude: number; name?: string };
821
+ type: "delivery" | "pickup";
822
+ notes?: string;
823
+ };
824
+ }
825
+ ```
826
+
827
+ Use it for type-safe intent handling:
828
+
829
+ ```tsx
830
+ import type { CreateErrandIntent } from '../invoke-types';
831
+
832
+ useInvoke({
833
+ intentId: 'create_errand',
834
+ onFire: (intent) => {
835
+ const typed = intent as CreateErrandIntent;
836
+ console.log(typed.parameters.type); // "delivery" | "pickup"
837
+ },
838
+ });
839
+ ```
840
+
841
+ ---
842
+
843
+ ## Testing
844
+
845
+ ```tsx
846
+ import {
847
+ mockInvoke,
848
+ simulateColdStart,
849
+ simulateWarmStart,
850
+ createMockIntent,
851
+ } from 'expo-invoke';
852
+ ```
853
+
854
+ ### mockInvoke
855
+
856
+ Feeds an intent directly into the action store. `useInvoke()` will fire on the next render.
857
+
858
+ ```ts
859
+ mockInvoke({ intentId: 'create_errand', source: 'siri' });
860
+ mockInvoke({ intentId: 'track_errand', source: 'widget', parameters: { errandId: '42' } });
861
+ ```
862
+
863
+ ### simulateColdStart
864
+
865
+ Simulates the app being closed when the intent fired. The intent is stored with a slightly past timestamp.
866
+
867
+ ```ts
868
+ simulateColdStart({ intentId: 'create_errand', ageMs: 500 });
869
+ // ageMs: how old the intent is (default 100ms) — keep under your TTL
870
+ ```
871
+
872
+ ### simulateWarmStart
873
+
874
+ Simulates the app being backgrounded when the intent fired (same as `mockInvoke` but named for clarity).
875
+
876
+ ```ts
877
+ simulateWarmStart({ intentId: 'create_errand', source: 'notification' });
878
+ ```
879
+
880
+ ### createMockIntent
881
+
882
+ Creates a `ResolvedIntent` object for use in assertions without storing it.
883
+
884
+ ```ts
885
+ const intent = createMockIntent({ intentId: 'test', source: 'nfc', parameters: { tag: 'abc' } });
886
+ expect(intent.source).toBe('nfc');
887
+ ```
888
+
889
+ ### Jest example
890
+
891
+ ```ts
892
+ import { mockInvoke } from 'expo-invoke';
893
+ import { renderHook, act } from '@testing-library/react-hooks';
894
+ import { useInvoke } from 'expo-invoke';
895
+
896
+ test('fires handler when matching intent arrives', () => {
897
+ const handler = jest.fn();
898
+ renderHook(() => useInvoke({ intentId: 'create_errand', onFire: handler }));
899
+
900
+ act(() => {
901
+ mockInvoke({ intentId: 'create_errand', source: 'siri' });
902
+ });
903
+
904
+ expect(handler).toHaveBeenCalledTimes(1);
905
+ expect(handler.mock.calls[0][0].source).toBe('siri');
906
+ });
907
+ ```
908
+
909
+ ---
910
+
911
+ ## Supported Surfaces
912
+
913
+ | Surface | iOS | Android | Source value |
914
+ |---|---|---|---|
915
+ | Voice (Siri) | ✓ App Intents (iOS 16+) | — | `siri` |
916
+ | Voice (Google Assistant) | — | ✓ App Actions BII | `google` |
917
+ | App icon long-press | ✓ App Shortcuts | ✓ Static/dynamic shortcuts | `shortcut` |
918
+ | Home screen widget | ✓ WidgetKit | ✓ AppWidgetProvider | `widget` |
919
+ | Dynamic Island tap | ✓ iOS 16.1+ | — | `live_activity` |
920
+ | Lock screen Live Activity | ✓ iOS 16.1+ | — | `live_activity` |
921
+ | Notification action button | ✓ UNNotificationAction | ✓ NotificationCompat.Action | `notification` |
922
+ | Notification body tap | ✓ | ✓ | `notification_tap` |
923
+ | iOS Spotlight | ✓ NSUserActivity | — | `spotlight` |
924
+ | iOS Control Center | ✓ iOS 18+ ControlWidget | — | `control_center` |
925
+ | Android Quick Settings | — | ✓ TileService | `quick_tile` |
926
+ | NFC tag tap | ✓ | ✓ | `nfc` |
927
+ | QR code scan | ✓ | ✓ | `qr` |
928
+ | Universal Link / App Link | ✓ | ✓ | `deep_link` |
929
+ | Custom URL scheme | ✓ | ✓ | `deep_link` |
930
+ | Share sheet | ✓ UIActivityViewController | ✓ Intent.ACTION_SEND | `share` |
931
+ | Apple Handoff | ✓ NSUserActivity | — | `handoff` |
932
+ | CarPlay | ✓ (phrases work automatically) | — | `carplay` |
933
+ | Android Auto | — | ✓ (via App Actions) | `android_auto` |
934
+ | Shortcuts automation | ✓ (automatic via App Intents) | — | `automation` |
935
+ | Siri Suggestions | ✓ via `useDonation()` | — | `siri` |
936
+ | watchOS tap/voice | ✓ WCSession | ✓ Wear Data Layer | `watch` |
937
+
938
+ ---
939
+
940
+ ## License
941
+
942
+ MIT — SmartHive Labs