Skip to content

Commit 1460c85

Browse files
evan-masseauclaude
andcommitted
feat(example): add permission helpers and domain hooks
Introduce PermissionHelper (location + push permission flows wrapping react-native-permissions and @react-native-firebase/messaging) plus four domain hooks — useAnalytics, useForms, useLocation, usePush — each owning the state and SDK calls for one Klaviyo feature area. Hooks provide handlers the UI layer can wire to buttons without touching the SDK directly. - Location permission flow requires separate taps for WhenInUse → Always on iOS (iOS won't prompt twice in one interaction) - usePush re-fetches APNs token on onTokenRefresh so Firebase's FCM token doesn't stomp the APNs token on iOS - Firebase availability is memoized at module scope Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent f133ecd commit 1460c85

5 files changed

Lines changed: 903 additions & 0 deletions

File tree

example/src/PermissionHelper.ts

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
import { Alert, Linking, Platform, PermissionsAndroid } from 'react-native';
2+
import { PERMISSIONS, RESULTS, check, request } from 'react-native-permissions';
3+
import { Klaviyo } from 'klaviyo-react-native-sdk';
4+
5+
export type LocationPermissionState = 'none' | 'inUse' | 'background';
6+
7+
// Module-scope memoization so we don't repeatedly `require()` or log.
8+
let _firebaseAvailable: boolean | null = null;
9+
10+
// Deep-link to the app's own Settings page. RN's Linking.openSettings()
11+
// uses UIApplication.openSettingsURLString on iOS and the app detail
12+
// settings intent on Android.
13+
const openAppSettings = () => {
14+
Linking.openSettings();
15+
};
16+
17+
/**
18+
* Helper to show modal when location permission is denied
19+
*/
20+
const handleDeniedPermissionModal = () => {
21+
Alert.alert(
22+
'Permission Required',
23+
'We need access to your location to provide accurate results and personalized services. Please enable location permissions in your device settings.',
24+
[
25+
{
26+
text: 'Cancel',
27+
style: 'cancel',
28+
},
29+
{
30+
text: 'Open Settings',
31+
onPress: () => openAppSettings(),
32+
},
33+
]
34+
);
35+
};
36+
37+
/**
38+
* Request location permissions for geofencing. Both platforms use a
39+
* two-tap flow — tap 1 requests foreground access, tap 2 upgrades to
40+
* background. On iOS the split is mandatory (iOS silently blocks
41+
* auto-chained upgrades). On Android it's by choice: auto-chaining would
42+
* make the foreground grant immediately trigger a settings redirect for
43+
* background access (Android 11+), which is a jarring UX for a demo.
44+
*
45+
* First call: requests foreground (WhenInUse / ACCESS_FINE_LOCATION) and
46+
* returns. Second call (with foreground already granted): requests
47+
* background (Always / ACCESS_BACKGROUND_LOCATION).
48+
*/
49+
export const requestLocationPermission = async () => {
50+
try {
51+
if (Platform.OS === 'ios') {
52+
const whenInUseStatus = await check(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
53+
const hasWhenInUse =
54+
whenInUseStatus === RESULTS.GRANTED ||
55+
whenInUseStatus === RESULTS.LIMITED;
56+
57+
if (!hasWhenInUse) {
58+
// First tap: request WhenInUse. Don't chain — let the user come back
59+
// and tap again to request Always.
60+
const whenInUseResult = await request(
61+
PERMISSIONS.IOS.LOCATION_WHEN_IN_USE
62+
);
63+
switch (whenInUseResult) {
64+
case RESULTS.UNAVAILABLE:
65+
console.log('Location permission is not available on this device.');
66+
return;
67+
case RESULTS.BLOCKED:
68+
handleDeniedPermissionModal();
69+
return;
70+
case RESULTS.DENIED:
71+
case RESULTS.GRANTED:
72+
case RESULTS.LIMITED:
73+
return;
74+
}
75+
}
76+
77+
// Second tap: WhenInUse already granted → upgrade to Always.
78+
const alwaysResult = await request(PERMISSIONS.IOS.LOCATION_ALWAYS);
79+
switch (alwaysResult) {
80+
case RESULTS.UNAVAILABLE:
81+
console.log(
82+
'Always location permission is not available on this device.'
83+
);
84+
break;
85+
case RESULTS.GRANTED:
86+
break;
87+
case RESULTS.DENIED:
88+
case RESULTS.BLOCKED:
89+
// iOS won't re-prompt for Always after WhenInUse was granted —
90+
// the user must change it in Settings manually.
91+
handleDeniedPermissionModal();
92+
break;
93+
case RESULTS.LIMITED:
94+
break;
95+
}
96+
} else if (Platform.OS === 'android') {
97+
const fineLocationStatus = await check(
98+
PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION
99+
);
100+
if (fineLocationStatus !== RESULTS.GRANTED) {
101+
const fineLocationResult = await request(
102+
PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION
103+
);
104+
105+
switch (fineLocationResult) {
106+
case RESULTS.UNAVAILABLE:
107+
console.log('Location permission is not available on this device.');
108+
return;
109+
case RESULTS.BLOCKED:
110+
case RESULTS.DENIED:
111+
console.log('Location permission was blocked or denied.');
112+
handleDeniedPermissionModal();
113+
return;
114+
case RESULTS.GRANTED:
115+
// Intentional: return after the foreground grant so the user
116+
// taps again to request background. See function JSDoc above.
117+
return;
118+
}
119+
}
120+
121+
if (Platform.Version >= 29) {
122+
const backgroundStatus = await check(
123+
PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION
124+
);
125+
if (backgroundStatus !== RESULTS.GRANTED) {
126+
const backgroundResult = await request(
127+
PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION
128+
);
129+
switch (backgroundResult) {
130+
case RESULTS.UNAVAILABLE:
131+
console.log(
132+
'Background location permission is not available on this device.'
133+
);
134+
break;
135+
case RESULTS.DENIED:
136+
console.log('Background location permission was denied by user.');
137+
break;
138+
case RESULTS.GRANTED:
139+
console.log('Background location permission granted!');
140+
break;
141+
case RESULTS.BLOCKED:
142+
console.log('Background location permission is blocked.');
143+
handleDeniedPermissionModal();
144+
break;
145+
}
146+
} else {
147+
console.log('Background location permission already granted.');
148+
}
149+
}
150+
}
151+
} catch (error) {
152+
console.error('Error requesting location permission:', error);
153+
}
154+
};
155+
156+
/**
157+
* Check the current location permission state
158+
* Returns: 'none', 'inUse', or 'background'
159+
*/
160+
export const checkLocationPermissionState =
161+
async (): Promise<LocationPermissionState> => {
162+
try {
163+
if (Platform.OS === 'ios') {
164+
// Check for Always permission first
165+
const alwaysStatus = await check(PERMISSIONS.IOS.LOCATION_ALWAYS);
166+
if (
167+
alwaysStatus === RESULTS.GRANTED ||
168+
alwaysStatus === RESULTS.LIMITED
169+
) {
170+
return 'background';
171+
}
172+
173+
// Check for When In Use permission
174+
const whenInUseStatus = await check(
175+
PERMISSIONS.IOS.LOCATION_WHEN_IN_USE
176+
);
177+
if (
178+
whenInUseStatus === RESULTS.GRANTED ||
179+
whenInUseStatus === RESULTS.LIMITED
180+
) {
181+
return 'inUse';
182+
}
183+
184+
return 'none';
185+
} else if (Platform.OS === 'android') {
186+
// Check fine location first
187+
const fineLocationStatus = await check(
188+
PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION
189+
);
190+
if (fineLocationStatus !== RESULTS.GRANTED) {
191+
return 'none';
192+
}
193+
194+
// Check background permission (Android 10+)
195+
if (Platform.Version >= 29) {
196+
const backgroundStatus = await check(
197+
PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION
198+
);
199+
if (backgroundStatus === RESULTS.GRANTED) {
200+
return 'background';
201+
}
202+
} else {
203+
// Android < 10 doesn't have separate background permission
204+
return 'background';
205+
}
206+
207+
return 'inUse';
208+
}
209+
210+
return 'none';
211+
} catch (error) {
212+
console.error('Error checking location permission state:', error);
213+
return 'none';
214+
}
215+
};
216+
217+
// =============================================================================
218+
// Push Notification Permissions
219+
// =============================================================================
220+
221+
/**
222+
* Check if Firebase push is configured and available.
223+
*
224+
* This probes for actual initialization, not just module resolvability. With
225+
* autolinking + unconditionally-linked iOS pods, the module always resolves
226+
* even when `GoogleService-Info.plist` is missing. In that case the native
227+
* `[FIRApp configure]` step is skipped, and any call into the messaging API
228+
* (e.g. `getToken()`) will throw "No Firebase App '[DEFAULT]' has been
229+
* created". We surface that here so the UI can degrade gracefully instead of
230+
* silently swallowing errors later.
231+
*
232+
* Calling `messaging()` itself throws when no default app exists (see
233+
* `@react-native-firebase/app/lib/internal/registry/app.js`), so the try/catch
234+
* captures both "module missing" and "not initialized" failure modes.
235+
* Accessing `.app` (a property on `FirebaseModule`) is a belt-and-suspenders
236+
* check in case the proxy ever returns a stub.
237+
*
238+
* Result is memoized on first call — we only probe once and avoid repeatedly
239+
* logging the "Firebase not available" message on every invocation.
240+
* @returns boolean - true if Firebase is properly configured AND initialized
241+
*/
242+
export const isFirebasePushAvailable = (): boolean => {
243+
if (_firebaseAvailable === null) {
244+
try {
245+
const messaging = require('@react-native-firebase/messaging').default;
246+
_firebaseAvailable = !!messaging().app;
247+
} catch {
248+
_firebaseAvailable = false;
249+
}
250+
}
251+
return _firebaseAvailable;
252+
};
253+
254+
/**
255+
* Get Firebase messaging instance if available
256+
* Returns null if Firebase is not configured
257+
*/
258+
export const getMessagingInstance = () => getMessaging();
259+
260+
const getMessaging = () => {
261+
if (!isFirebasePushAvailable()) {
262+
return null;
263+
}
264+
try {
265+
const messaging = require('@react-native-firebase/messaging').default;
266+
return messaging();
267+
} catch (error) {
268+
console.error('Error getting Firebase messaging:', error);
269+
return null;
270+
}
271+
};
272+
273+
/**
274+
* Request push notification permissions
275+
* On iOS: Requests notification permission via Firebase messaging
276+
* On Android: Requests POST_NOTIFICATIONS permission (Android 13+)
277+
* @returns Promise<boolean> - true if permission granted, false otherwise
278+
*/
279+
export const requestPushPermission = async (): Promise<boolean> => {
280+
const messaging = getMessaging();
281+
if (!messaging) {
282+
console.warn(
283+
'Firebase is not configured. Please add google-services.json (Android) or GoogleService-Info.plist (iOS) to enable push notifications.'
284+
);
285+
return false;
286+
}
287+
288+
try {
289+
let isAuthorized = false;
290+
291+
if (Platform.OS === 'android') {
292+
// POST_NOTIFICATIONS is only a runtime permission on Android 13+ (API 33).
293+
// Older versions auto-grant notifications — requesting the permission
294+
// there returns `never_ask_again`, which would look like a denial here.
295+
if (Platform.Version >= 33) {
296+
const androidAuthStatus = await PermissionsAndroid.request(
297+
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
298+
);
299+
isAuthorized = androidAuthStatus === 'granted';
300+
} else {
301+
isAuthorized = true;
302+
}
303+
} else if (Platform.OS === 'ios') {
304+
const iOsAuthStatus = await messaging.requestPermission();
305+
const AuthorizationStatus = require('@react-native-firebase/messaging')
306+
.default.AuthorizationStatus;
307+
isAuthorized =
308+
iOsAuthStatus === AuthorizationStatus.AUTHORIZED ||
309+
iOsAuthStatus === AuthorizationStatus.PROVISIONAL;
310+
}
311+
312+
if (isAuthorized) {
313+
console.log('User has notification permissions enabled.');
314+
} else {
315+
console.log('User has notification permissions disabled');
316+
}
317+
318+
return isAuthorized;
319+
} catch (error) {
320+
console.error('Error requesting push permission:', error);
321+
return false;
322+
}
323+
};
324+
325+
/**
326+
* Fetch push token from Firebase and set it on the Klaviyo SDK.
327+
* Android: FCM registration token via messaging().getToken().
328+
* iOS: APNs device token via messaging().getAPNSToken(). Firebase populates
329+
* it after iOS fires didRegisterForRemoteNotificationsWithDeviceToken,
330+
* which is triggered by messaging().requestPermission() when the user
331+
* grants notifications. On cold start pre-permission, getAPNSToken
332+
* returns null — the Request Push Permission button flow calls this
333+
* function again post-grant to populate it.
334+
*
335+
* @returns Promise<string | null> - the push token or null if unavailable
336+
*/
337+
export const fetchAndSetPushToken = async (): Promise<string | null> => {
338+
const messaging = getMessaging();
339+
if (!messaging) return null;
340+
341+
try {
342+
const deviceToken =
343+
Platform.OS === 'android'
344+
? await messaging.getToken()
345+
: await messaging.getAPNSToken();
346+
347+
if (deviceToken != null && deviceToken.length > 0) {
348+
Klaviyo.setPushToken(deviceToken);
349+
return deviceToken;
350+
}
351+
return null;
352+
} catch (error) {
353+
console.error('[PermissionHelper] error in fetchAndSetPushToken:', error);
354+
return null;
355+
}
356+
};
357+
358+
/**
359+
* Check if push notifications are authorized
360+
* @returns Promise<boolean> - true if authorized, false otherwise
361+
*/
362+
export const checkPushPermissionStatus = async (): Promise<boolean> => {
363+
const messaging = getMessaging();
364+
if (!messaging) {
365+
return false;
366+
}
367+
368+
try {
369+
const status = await messaging.hasPermission();
370+
const AuthorizationStatus = require('@react-native-firebase/messaging')
371+
.default.AuthorizationStatus;
372+
return (
373+
status === AuthorizationStatus.AUTHORIZED ||
374+
status === AuthorizationStatus.PROVISIONAL
375+
);
376+
} catch (error) {
377+
console.error('Error checking push permission status:', error);
378+
return false;
379+
}
380+
};

0 commit comments

Comments
 (0)