Skip to content

Commit 5a84fdf

Browse files
evan-masseauclaude
authored 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 c61f477 commit 5a84fdf

5 files changed

Lines changed: 893 additions & 0 deletions

File tree

example/src/PermissionHelper.ts

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
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 = () => {
259+
if (!isFirebasePushAvailable()) {
260+
return null;
261+
}
262+
try {
263+
const messaging = require('@react-native-firebase/messaging').default;
264+
return messaging();
265+
} catch (error) {
266+
console.error('Error getting Firebase messaging:', error);
267+
return null;
268+
}
269+
};
270+
271+
/**
272+
* Request push notification permissions
273+
* On iOS: Requests notification permission via Firebase messaging
274+
* On Android: Requests POST_NOTIFICATIONS permission (Android 13+)
275+
* @returns Promise<boolean> - true if permission granted, false otherwise
276+
*/
277+
export const requestPushPermission = async (): Promise<boolean> => {
278+
const messaging = getMessagingInstance();
279+
if (!messaging) {
280+
console.warn(
281+
'Firebase is not configured. Please add google-services.json (Android) or GoogleService-Info.plist (iOS) to enable push notifications.'
282+
);
283+
return false;
284+
}
285+
286+
try {
287+
let isAuthorized = false;
288+
289+
if (Platform.OS === 'android') {
290+
// POST_NOTIFICATIONS is only a runtime permission on Android 13+ (API 33).
291+
// Older versions auto-grant notifications — requesting the permission
292+
// there returns `never_ask_again`, which would look like a denial here.
293+
if (Platform.Version >= 33) {
294+
const androidAuthStatus = await PermissionsAndroid.request(
295+
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
296+
);
297+
isAuthorized = androidAuthStatus === 'granted';
298+
} else {
299+
isAuthorized = true;
300+
}
301+
} else if (Platform.OS === 'ios') {
302+
const iOsAuthStatus = await messaging.requestPermission();
303+
const AuthorizationStatus = require('@react-native-firebase/messaging')
304+
.default.AuthorizationStatus;
305+
isAuthorized =
306+
iOsAuthStatus === AuthorizationStatus.AUTHORIZED ||
307+
iOsAuthStatus === AuthorizationStatus.PROVISIONAL;
308+
}
309+
310+
if (isAuthorized) {
311+
console.log('User has notification permissions enabled.');
312+
} else {
313+
console.log('User has notification permissions disabled');
314+
}
315+
316+
return isAuthorized;
317+
} catch (error) {
318+
console.error('Error requesting push permission:', error);
319+
return false;
320+
}
321+
};
322+
323+
/**
324+
* Fetch push token from Firebase and set it on the Klaviyo SDK.
325+
* Android: FCM registration token via messaging().getToken().
326+
* iOS: APNs device token via messaging().getAPNSToken(). Firebase populates
327+
* it after iOS fires didRegisterForRemoteNotificationsWithDeviceToken,
328+
* which is triggered by messaging().requestPermission() when the user
329+
* grants notifications. On cold start pre-permission, getAPNSToken
330+
* returns null — the Request Push Permission button flow calls this
331+
* function again post-grant to populate it.
332+
*
333+
* @returns Promise<string | null> - the push token or null if unavailable
334+
*/
335+
export const fetchAndSetPushToken = async (): Promise<string | null> => {
336+
const messaging = getMessagingInstance();
337+
if (!messaging) return null;
338+
339+
try {
340+
const deviceToken =
341+
Platform.OS === 'android'
342+
? await messaging.getToken()
343+
: await messaging.getAPNSToken();
344+
345+
if (deviceToken != null && deviceToken.length > 0) {
346+
Klaviyo.setPushToken(deviceToken);
347+
return deviceToken;
348+
}
349+
return null;
350+
} catch (error) {
351+
console.error('[PermissionHelper] error in fetchAndSetPushToken:', error);
352+
return null;
353+
}
354+
};
355+
356+
/**
357+
* Check if push notifications are authorized
358+
* @returns Promise<boolean> - true if authorized, false otherwise
359+
*/
360+
export const checkPushPermissionStatus = async (): Promise<boolean> => {
361+
const messaging = getMessagingInstance();
362+
if (!messaging) {
363+
return false;
364+
}
365+
366+
try {
367+
const status = await messaging.hasPermission();
368+
const AuthorizationStatus = require('@react-native-firebase/messaging')
369+
.default.AuthorizationStatus;
370+
return (
371+
status === AuthorizationStatus.AUTHORIZED ||
372+
status === AuthorizationStatus.PROVISIONAL
373+
);
374+
} catch (error) {
375+
console.error('Error checking push permission status:', error);
376+
return false;
377+
}
378+
};

0 commit comments

Comments
 (0)