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.
- package/MIGRATION.md +125 -0
- package/README.md +942 -0
- package/SECURITY.md +35 -0
- package/android/build.gradle +42 -0
- package/android/src/main/java/expo/modules/invoke/AppActionsHandler.kt +74 -0
- package/android/src/main/java/expo/modules/invoke/ExpoInvokeModule.kt +135 -0
- package/android/src/main/java/expo/modules/invoke/QuickTileService.kt +63 -0
- package/android/src/main/java/expo/modules/invoke/ShortcutsHelper.kt +72 -0
- package/build/cli/commands/generate.d.ts +1 -0
- package/build/cli/commands/generate.js +119 -0
- package/build/cli/commands/generate.js.map +1 -0
- package/build/cli/commands/test.d.ts +1 -0
- package/build/cli/commands/test.js +53 -0
- package/build/cli/commands/test.js.map +1 -0
- package/build/cli/commands/validate.d.ts +1 -0
- package/build/cli/commands/validate.js +116 -0
- package/build/cli/commands/validate.js.map +1 -0
- package/build/cli/index.d.ts +2 -0
- package/build/cli/index.js +37 -0
- package/build/cli/index.js.map +1 -0
- package/build/plugin/android/withAndroidInvoke.d.ts +8 -0
- package/build/plugin/android/withAndroidInvoke.js +106 -0
- package/build/plugin/android/withAndroidInvoke.js.map +1 -0
- package/build/plugin/codegen/generateActionsXml.d.ts +2 -0
- package/build/plugin/codegen/generateActionsXml.js +69 -0
- package/build/plugin/codegen/generateActionsXml.js.map +1 -0
- package/build/plugin/codegen/generateShortcutsXml.d.ts +2 -0
- package/build/plugin/codegen/generateShortcutsXml.js +45 -0
- package/build/plugin/codegen/generateShortcutsXml.js.map +1 -0
- package/build/plugin/codegen/generateSwiftIntents.d.ts +2 -0
- package/build/plugin/codegen/generateSwiftIntents.js +129 -0
- package/build/plugin/codegen/generateSwiftIntents.js.map +1 -0
- package/build/plugin/ios/withIOSInvoke.d.ts +9 -0
- package/build/plugin/ios/withIOSInvoke.js +83 -0
- package/build/plugin/ios/withIOSInvoke.js.map +1 -0
- package/build/plugin/src/withInvoke.d.ts +16 -0
- package/build/plugin/src/withInvoke.js +13 -0
- package/build/plugin/src/withInvoke.js.map +1 -0
- package/build/src/components/AddToSiriButton.d.ts +18 -0
- package/build/src/components/AddToSiriButton.d.ts.map +1 -0
- package/build/src/components/AddToSiriButton.js +35 -0
- package/build/src/components/AddToSiriButton.js.map +1 -0
- package/build/src/components/InvokeDebugger.d.ts +12 -0
- package/build/src/components/InvokeDebugger.d.ts.map +1 -0
- package/build/src/components/InvokeDebugger.js +122 -0
- package/build/src/components/InvokeDebugger.js.map +1 -0
- package/build/src/components/ShortcutChip.d.ts +20 -0
- package/build/src/components/ShortcutChip.d.ts.map +1 -0
- package/build/src/components/ShortcutChip.js +44 -0
- package/build/src/components/ShortcutChip.js.map +1 -0
- package/build/src/hooks/useAddToSiri.d.ts +9 -0
- package/build/src/hooks/useAddToSiri.d.ts.map +1 -0
- package/build/src/hooks/useAddToSiri.js +15 -0
- package/build/src/hooks/useAddToSiri.js.map +1 -0
- package/build/src/hooks/useDonation.d.ts +26 -0
- package/build/src/hooks/useDonation.d.ts.map +1 -0
- package/build/src/hooks/useDonation.js +21 -0
- package/build/src/hooks/useDonation.js.map +1 -0
- package/build/src/hooks/useFocusFilter.d.ts +11 -0
- package/build/src/hooks/useFocusFilter.d.ts.map +1 -0
- package/build/src/hooks/useFocusFilter.js +24 -0
- package/build/src/hooks/useFocusFilter.js.map +1 -0
- package/build/src/hooks/useHandoff.d.ts +29 -0
- package/build/src/hooks/useHandoff.d.ts.map +1 -0
- package/build/src/hooks/useHandoff.js +27 -0
- package/build/src/hooks/useHandoff.js.map +1 -0
- package/build/src/hooks/useInvoke.d.ts +43 -0
- package/build/src/hooks/useInvoke.d.ts.map +1 -0
- package/build/src/hooks/useInvoke.js +70 -0
- package/build/src/hooks/useInvoke.js.map +1 -0
- package/build/src/hooks/useInvokeAnalytics.d.ts +23 -0
- package/build/src/hooks/useInvokeAnalytics.d.ts.map +1 -0
- package/build/src/hooks/useInvokeAnalytics.js +27 -0
- package/build/src/hooks/useInvokeAnalytics.js.map +1 -0
- package/build/src/hooks/useInvokeHistory.d.ts +11 -0
- package/build/src/hooks/useInvokeHistory.d.ts.map +1 -0
- package/build/src/hooks/useInvokeHistory.js +26 -0
- package/build/src/hooks/useInvokeHistory.js.map +1 -0
- package/build/src/hooks/useInvokeInit.d.ts +16 -0
- package/build/src/hooks/useInvokeInit.d.ts.map +1 -0
- package/build/src/hooks/useInvokeInit.js +32 -0
- package/build/src/hooks/useInvokeInit.js.map +1 -0
- package/build/src/hooks/useLiveActivityIntent.d.ts +21 -0
- package/build/src/hooks/useLiveActivityIntent.d.ts.map +1 -0
- package/build/src/hooks/useLiveActivityIntent.js +9 -0
- package/build/src/hooks/useLiveActivityIntent.js.map +1 -0
- package/build/src/hooks/useQuickTile.d.ts +23 -0
- package/build/src/hooks/useQuickTile.d.ts.map +1 -0
- package/build/src/hooks/useQuickTile.js +15 -0
- package/build/src/hooks/useQuickTile.js.map +1 -0
- package/build/src/hooks/useShortcuts.d.ts +25 -0
- package/build/src/hooks/useShortcuts.d.ts.map +1 -0
- package/build/src/hooks/useShortcuts.js +23 -0
- package/build/src/hooks/useShortcuts.js.map +1 -0
- package/build/src/hooks/useSpotlight.d.ts +23 -0
- package/build/src/hooks/useSpotlight.d.ts.map +1 -0
- package/build/src/hooks/useSpotlight.js +13 -0
- package/build/src/hooks/useSpotlight.js.map +1 -0
- package/build/src/hooks/useWidgetData.d.ts +20 -0
- package/build/src/hooks/useWidgetData.d.ts.map +1 -0
- package/build/src/hooks/useWidgetData.js +12 -0
- package/build/src/hooks/useWidgetData.js.map +1 -0
- package/build/src/index.d.ts +32 -0
- package/build/src/index.d.ts.map +1 -0
- package/build/src/index.js +27 -0
- package/build/src/index.js.map +1 -0
- package/build/src/module.d.ts +25 -0
- package/build/src/module.d.ts.map +1 -0
- package/build/src/module.js +102 -0
- package/build/src/module.js.map +1 -0
- package/build/src/types/index.d.ts +156 -0
- package/build/src/types/index.d.ts.map +1 -0
- package/build/src/types/index.js +4 -0
- package/build/src/types/index.js.map +1 -0
- package/build/src/utils/actionStore.d.ts +6 -0
- package/build/src/utils/actionStore.d.ts.map +1 -0
- package/build/src/utils/actionStore.js +32 -0
- package/build/src/utils/actionStore.js.map +1 -0
- package/build/src/utils/analytics.d.ts +12 -0
- package/build/src/utils/analytics.d.ts.map +1 -0
- package/build/src/utils/analytics.js +29 -0
- package/build/src/utils/analytics.js.map +1 -0
- package/build/src/utils/testing.d.ts +39 -0
- package/build/src/utils/testing.d.ts.map +1 -0
- package/build/src/utils/testing.js +58 -0
- package/build/src/utils/testing.js.map +1 -0
- package/build/src/utils/validation.d.ts +11 -0
- package/build/src/utils/validation.d.ts.map +1 -0
- package/build/src/utils/validation.js +128 -0
- package/build/src/utils/validation.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Focus/FocusFilterManager.swift +19 -0
- package/ios/Intents/BaseInvokeIntent.swift +42 -0
- package/ios/Intents/HandoffManager.swift +49 -0
- package/ios/Intents/InvokeActionStore.swift +21 -0
- package/ios/Intents/InvokeParameterTypes.swift +142 -0
- package/ios/LiveActivity/LiveActivityIntentBridge.swift +44 -0
- package/ios/Module/ExpoInvokeModule.swift +170 -0
- package/ios/Notifications/NotificationActionHandler.swift +80 -0
- package/ios/Shortcuts/DonationManager.swift +44 -0
- package/ios/Shortcuts/ShortcutsManager.swift +32 -0
- package/ios/Shortcuts/SpotlightManager.swift +48 -0
- package/ios/Widget/WidgetManager.swift +19 -0
- 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
|