Device Features
Wiring the camera upload, push notifications and encrypted session storage into the app, and handling the permission a user declines.
const photo = await camera.current?.takePictureAsync({ quality: 0.6 })Three device features finish the app: the camera, notifications, and somewhere safe to keep a token. Each has the same shape. Ask for permission at the moment the feature is used, handle the refusal as a normal state rather than an error, and keep the native call behind a module of your own.
The camera
useCameraPermissions returns a response and a request function. The response is null on the first render, before the hook has read the current grant, so there are three states to draw and not two. takePictureAsync resolves to an object whose uri is a local file:// path in the cache directory. That path is what gets uploaded, and it is not permanent: the system can clear the cache.
Notifications
A push token is not free to obtain. It needs permission, an Android channel, and a projectId, which is why getExpoPushTokenAsync fails in a project with no EAS id configured. setNotificationHandler decides what happens to a notification that arrives while the app is open; its handler returns shouldShowBanner and shouldShowList, the two fields that replaced a single shouldShowAlert in older code.
The session
SecureStore keeps small values in the iOS keychain and in Android's Keystore-backed storage. Tokens only. Large values can be rejected outright, and iOS has historically refused anything much over 2048 bytes, so the user profile goes to AsyncStorage or SQLite instead.
Example
// app/capture.tsx
// On screen: the live camera filling the frame, with a dark rounded
// Capture button near the bottom. A tap uploads the photo and the label
// reads Uploading until the request settles. If the permission has been
// refused, the camera is replaced by one line of text and a button.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CameraView, useCameraPermissions } from "expo-camera";
import { useRef } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { entryKeys, uploadPhoto } from "../src/api/entries";
export default function Capture() {
// Every hook runs here, in this order, on every render. The early
// returns below are only safe because no hook comes after them.
const [permission, requestPermission] = useCameraPermissions();
const camera = useRef<CameraView>(null);
const queryClient = useQueryClient();
const upload = useMutation({
mutationFn: uploadPhoto,
onSuccess: () => queryClient.invalidateQueries({ queryKey: entryKeys.feed }),
});
if (!permission) return <View style={styles.fill} />; // grant not read yet
if (!permission.granted) {
return (
<View style={styles.center}>
<Text>Fieldlog needs the camera to attach a photo.</Text>
<Pressable onPress={requestPermission}>
<Text>Allow camera</Text>
</Pressable>
</View>
);
}
async function take() {
// ?. is shorthand for: camera.current && camera.current.takePictureAsync(...)
const photo = await camera.current?.takePictureAsync({ quality: 0.6 });
if (photo) upload.mutate(photo.uri);
}
return (
<View style={styles.fill}>
<CameraView ref={camera} style={styles.fill} facing="back" />
<Pressable style={styles.shutter} onPress={take} disabled={upload.isPending}>
<Text style={styles.label}>{upload.isPending ? "Uploading" : "Capture"}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
center: { flex: 1, justifyContent: "center", alignItems: "center", gap: 12 },
// position takes "relative", "absolute" or "static"; there is no
// "fixed". zIndex does exist and works like the CSS property, but it is
// rarely needed: a later sibling already draws on top.
shutter: {
position: "absolute",
bottom: 48,
alignSelf: "center",
backgroundColor: "#111",
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 28,
},
label: { color: "#fff" },
});When to use it
- Taking a photo in the app and attaching it to a record with a visible upload state.
- Registering a device for push after sign-in and sending the token to your server.
- Keeping a refresh token off disk in plain text while the user's preferences stay in AsyncStorage.
More examples
The session in encrypted storage
Three functions, one key, nothing else. Wrapping SecureStore like this means the rest of the app never imports a native module, which is what makes the screens testable and the storage swappable. Use it for refresh tokens, API keys and anything else whose leak would matter; use AsyncStorage for preferences.
// src/session/store.ts
import * as SecureStore from "expo-secure-store";
import type { Session } from "../domain/session";
const KEY = "fieldlog.session";
export async function saveSession(session: Session): Promise<void> {
// Tokens only. Large values can be rejected by the platform, and iOS
// has historically refused values above roughly 2048 bytes.
await SecureStore.setItemAsync(KEY, JSON.stringify(session));
}
export async function loadSession(): Promise<Session | null> {
const raw = await SecureStore.getItemAsync(KEY);
// The ternary is the longhand of raw && JSON.parse(raw), with the
// difference that this one returns null rather than an empty string.
return raw ? (JSON.parse(raw) as Session) : null;
}
export async function clearSession(): Promise<void> {
// Sign-out must also clear the query cache, or the next user to sign in
// on this device sees the previous user's feed from disk.
await SecureStore.deleteItemAsync(KEY);
}Registering for push
Call this after sign-in, not on launch: the permission prompt lands better once the user knows what the app is. The returned string is what the server stores against the user. Checking the existing permission before requesting it matters because the system prompt only ever appears once, and a second request on iOS resolves without showing anything.
// src/notifications/register.ts
import Constants from "expo-constants";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
// What happens to a notification that arrives while the app is open.
// shouldShowBanner and shouldShowList are the two fields that replaced
// the single shouldShowAlert older code returns.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
export async function registerForPush(): Promise<string | null> {
if (Platform.OS === "android") {
// Android needs a channel before anything can be delivered to it.
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const current = await Notifications.getPermissionsAsync();
const settled = current.granted
? current
: await Notifications.requestPermissionsAsync();
if (!settled.granted) return null; // a refusal is a state, not an error
const projectId = Constants.expoConfig?.extra?.eas?.projectId as string | undefined;
if (!projectId) return null;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
return token.data;
}Opening the right screen from a tap
The hook form handles the cold-start case that a listener cannot: by the time a listener is attached, the tap that launched the app has already happened. Guarding the payload with isPushData stops a malformed notification from navigating to /entry/undefined. Reach for this in any app whose notifications are supposed to land somewhere specific.
// src/notifications/useNotificationRouting.ts
import * as Notifications from "expo-notifications";
import { useRouter } from "expo-router";
import { useEffect } from "react";
import { isPushData } from "../domain/push";
// Called once from the root layout. It covers both cases: a tap while the
// app is running, and a tap that launched the app from cold.
export function useNotificationRouting() {
const router = useRouter();
// undefined while it is still being read, null when the app was not
// opened from a notification.
const last = Notifications.useLastNotificationResponse();
useEffect(() => {
const data = last?.notification.request.content.data;
if (isPushData(data)) {
router.push(`/entry/${data.entryId}`);
}
}, [last, router]);
}
Discussion