Device features: camera, location and notifications
Add a QR scanner, a distance-to-shop feature and a local notification to the Brew & Bean app with Expo. Learn how permissions work and how to handle refusals, plus what Expo Go can and cannot do.
Phones have a camera, GPS and the ability to notify you when the app is closed. These are the reasons to build a mobile app instead of a website. In this tutorial you add three features to the Brew & Bean app: scanning a pickup QR code with the camera, finding how far you are from the shop with location, and a local notification ("your order is ready"). You will also learn how permissions work, which is the part beginners get wrong.
Prerequisites: the earlier lessons in this track (an Expo app with tabs and a Stack). Test on a real phone with Expo Go.
How permissions work
Camera, location and notifications are private, so the operating system makes your app ask. The pattern is always the same:
1. Check whether you already have permission.
2. If not, ask. The system shows its own dialog; you cannot style it.
3. If the user says no, handle it: explain why you need it and what still works.
4. If they chose "don't ask again", you cannot ask a second time. Offer a button that opens the app's settings with Linking.openSettings().
Ask at the moment the feature is used, not when the app opens. A user who has just tapped "Scan code" understands why the camera is requested.
Plan
- 1Install the three libraries: run the command in the next block from the project folder.
- 2Restart the dev server after installing (stop it with Ctrl+C, then
npx expo start). - 3Build each feature below as its own screen so you can test it alone.
npx expo install expo-camera expo-location expo-notificationsAlways use npx expo install for Expo libraries so versions match your SDK.
Feature 1: Scan a pickup QR code
Create app/scan.js. useCameraPermissions gives the current permission and a function to request it. CameraView shows the live camera and can detect QR codes: its onBarcodeScanned function receives an object whose data field holds the text in the code.
import { useState } from "react";
import { View, Text, Pressable, Linking, StyleSheet } from "react-native";
import { CameraView, useCameraPermissions } from "expo-camera";
export default function Scan() {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(null);
// Permission status is still loading
if (!permission) {
return <View style={styles.center} />;
}
// Not granted yet: explain, then ask
if (!permission.granted) {
return (
<View style={styles.center}>
<Text style={styles.message}>
We use the camera only to scan the pickup code on your receipt.
</Text>
{permission.canAskAgain ? (
<Pressable style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Allow camera</Text>
</Pressable>
) : (
<Pressable style={styles.button} onPress={() => Linking.openSettings()}>
<Text style={styles.buttonText}>Open settings</Text>
</Pressable>
)}
</View>
);
}
return (
<View style={styles.flex}>
<CameraView
style={styles.flex}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={scanned ? undefined : ({ data }) => setScanned(data)}
/>
{scanned && (
<View style={styles.result}>
<Text style={styles.resultText}>Scanned: {scanned}</Text>
<Pressable style={styles.button} onPress={() => setScanned(null)}>
<Text style={styles.buttonText}>Scan again</Text>
</Pressable>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: "#fff8f0" },
message: { fontSize: 16, textAlign: "center", color: "#2b1a0e", marginBottom: 20 },
button: { backgroundColor: "#4a2c17", paddingVertical: 12, paddingHorizontal: 24, borderRadius: 10 },
buttonText: { color: "white", fontSize: 16 },
result: { position: "absolute", bottom: 40, left: 20, right: 20, backgroundColor: "white", padding: 16, borderRadius: 12, alignItems: "center", gap: 12 },
resultText: { fontSize: 16, color: "#2b1a0e" },
});While scanned has a value, onBarcodeScanned is set to undefined, so the camera stops reporting the same code many times a second.
Register the screen's title in app/_layout.js by adding one line inside the Stack:
<Stack.Screen name="scan" options={{ title: "Scan pickup code" }} />To test it, open any QR code generator in your laptop's browser, make a code with the text BB-1042 and point your phone at the screen.
Feature 2: How far is the shop?
expo-location gives you the phone's position. There are two permission levels: foreground (only while the app is open) and background (always). Request the smallest one you need. Foreground is enough here. The distance is calculated with the haversine formula, which finds the distance between two points on a sphere.
import { useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import * as Location from "expo-location";
// Approximate position of Indiranagar, Bengaluru. Replace with your own shop's coordinates.
const SHOP = { lat: 12.9719, lng: 77.6412 };
function distanceKm(lat1, lng1, lat2, lng2) {
const toRad = (deg) => (deg * Math.PI) / 180;
const R = 6371; // Earth radius in km
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
export default function Nearby() {
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
async function findDistance() {
setBusy(true);
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
setMessage("Location is off. You can still order, we just cannot show the distance.");
return;
}
const position = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
const km = distanceKm(position.coords.latitude, position.coords.longitude, SHOP.lat, SHOP.lng);
setMessage("You are about " + km.toFixed(1) + " km from Brew & Bean.");
} catch (e) {
setMessage("Could not get your location. Is GPS switched on?");
} finally {
setBusy(false);
}
}
return (
<View style={styles.box}>
<Pressable style={styles.button} onPress={findDistance} disabled={busy}>
<Text style={styles.buttonText}>{busy ? "Finding..." : "How far is the shop?"}</Text>
</Pressable>
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
box: { marginTop: 16 },
button: { backgroundColor: "#4a2c17", padding: 14, borderRadius: 10, alignItems: "center" },
buttonText: { color: "white", fontSize: 16 },
message: { marginTop: 12, fontSize: 16, color: "#2b1a0e" },
});A component we will place on a new tab screen next. Balanced accuracy uses less battery than High accuracy and is fine for a distance in kilometres.
Treat location as personal data
Location is sensitive personal data. Ask only when needed, say why, never collect more than you use, and do not send it to a server without telling the user. In India, the DPDP Act expects clear purpose and consent for personal data.
Feature 3: A local notification
A local notification is scheduled by the app itself on the device: reminders, timers, "order ready" alerts when the app already knows the time. A push notification is sent from a server through Google's or Apple's service when something happens remotely, such as the shop marking an order ready. Push needs a server, device tokens and a development build, and is beyond this lesson. Local notifications are the right place to learn the basics.
Three things are needed: a handler that decides what happens when a notification arrives while the app is open, a channel on Android (required from Android 8 onwards), and the user's permission (required from Android 13 onwards, and on iOS).
import { useState } from "react";
import { View, Text, Pressable, Platform, StyleSheet } from "react-native";
import * as Notifications from "expo-notifications";
import NearbyShop from "../../components/NearbyShop";
import { Link } from "expo-router";
// Show notifications even when the app is open
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
export default function Pickup() {
const [info, setInfo] = useState("");
async function scheduleReminder() {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "Order updates",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
let { status } = await Notifications.getPermissionsAsync();
if (status !== "granted") {
({ status } = await Notifications.requestPermissionsAsync());
}
if (status !== "granted") {
setInfo("Notifications are off. Turn them on in the phone settings to get order updates.");
return;
}
await Notifications.scheduleNotificationAsync({
content: {
title: "Your order is ready",
body: "Your cappuccino is waiting at Brew & Bean.",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 5,
},
});
setInfo("Reminder set. It will appear in 5 seconds. Try sending the app to the background.");
}
return (
<View style={styles.container}>
<Link href="/scan" asChild>
<Pressable style={styles.button}>
<Text style={styles.buttonText}>Scan pickup code</Text>
</Pressable>
</Link>
<NearbyShop />
<Pressable style={[styles.button, styles.spaced]} onPress={scheduleReminder}>
<Text style={styles.buttonText}>Remind me in 5 seconds</Text>
</Pressable>
{info ? <Text style={styles.info}>{info}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: "#fff8f0" },
button: { backgroundColor: "#4a2c17", padding: 14, borderRadius: 10, alignItems: "center" },
spaced: { marginTop: 16 },
buttonText: { color: "white", fontSize: 16 },
info: { marginTop: 12, fontSize: 16, color: "#2b1a0e" },
});One screen that links to the scanner, shows the distance component and schedules a local notification. The handler runs once when this file is loaded.
Add the screen as a tab by putting this inside the Tabs in app/(tabs)/_layout.js:
<Tabs.Screen
name="pickup"
options={{
title: "Pickup",
tabBarIcon: ({ color, size }) => <Ionicons name="location-outline" size={size} color={color} />,
}}
/>The new Pickup tab has three buttons. "Scan pickup code" asks for the camera, then shows the live camera and the text of any QR code you scan. "How far is the shop?" asks for location and shows a distance in kilometres. "Remind me in 5 seconds" asks for notification permission, and five seconds later a notification titled "Your order is ready" appears.Expo Go has limits
Expo changes what Expo Go supports from time to time. At the time of writing (2026), local notifications work in Expo Go, but remote push notifications on Android need a development build. If a feature fails in Expo Go with a message about it being unsupported, check the Expo documentation for that library and create a development build.
Telling the stores why you need access
In a built app, permissions are declared in the app's settings. Most Expo libraries are configured as config plugins in app.json, where you can write the message the user sees. For example, the camera plugin takes a cameraPermission text. Write a plain, honest reason, because store reviewers and users read it:
{
"expo": {
"plugins": [
[
"expo-camera",
{ "cameraPermission": "Brew & Bean uses the camera to scan your pickup code." }
],
[
"expo-location",
{ "locationWhenInUsePermission": "Brew & Bean uses your location to show how far the shop is." }
]
]
}
}These texts take effect in builds. Expo Go uses its own permission messages. Check each library's documentation page for the exact option names.
Common mistakes
Watch out for
- Asking for every permission on the first screen. Ask when the feature is used.
- Not handling a refusal. Always show what still works and a way to open settings.
- Requesting background location when foreground is enough.
- Forgetting the Android notification channel, so notifications silently do not appear on new Android versions.
- Testing only on an emulator. Camera, GPS and notifications behave best on a real phone.
I am building a mobile app for [describe your app]. The feature [camera / location / notifications] is used for [what it does]. Write: (1) a one-sentence permission message for the operating system dialog, (2) a short screen text explaining the benefit to the user before I ask, and (3) text for when the user has refused. Use plain English, no jargon, and do not ask for more than the feature needs.What to expect: A good answer is honest and specific to the feature, short enough for a dialog, and offers a fallback for refusal. Check that nothing in it promises something your app does not do.
Try it: Make the reminder useful
Change the notification so it fires at a time chosen by the user: add buttons for 10 and 30 minutes (seconds: 600 and seconds: 1800) and show the chosen time in the message. Then add a "Cancel reminders" button.
Key takeaways
- Ask for permissions at the moment of use, explain why, and handle refusal, including a path to settings.
- expo-camera's CameraView with onBarcodeScanned reads QR codes; guard it so it does not fire repeatedly.
- expo-location needs foreground permission for most features; ask for the least access you need.
- Local notifications are scheduled on the device; push notifications need a server and a development build.
- Install Expo libraries with npx expo install, and test device features on a real phone.
Check yourself
Finishing the quiz marks the lesson as done.
1. A user denied camera permission and chose not to be asked again. What should your app do?
2. When is the best time to ask for camera permission?
3. What is the difference between a local and a push notification?
4. Why does the scanner set onBarcodeScanned to undefined after the first scan?
5. Which location permission should you request for showing the distance to a shop while the app is open?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.