Skip to content

Commit a7531e4

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 fe70350 commit a7531e4

5 files changed

Lines changed: 890 additions & 0 deletions

File tree

example/src/PermissionHelper.ts

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

0 commit comments

Comments
 (0)