expo-app-blocker
v0.1.25
Published
Expo module for cross-platform app blocking. Android: UsageStatsManager + Overlay. iOS: Screen Time API (FamilyControls + ManagedSettings + DeviceActivity).
Maintainers
Readme
expo-app-blocker
Cross-platform app blocking module for Expo. Block other apps and redirect users to your app.
Android: UsageStatsManager + Foreground Service + System Overlay iOS: Screen Time API (FamilyControls + ManagedSettings + DeviceActivity)
Demo
https://github.com/user-attachments/assets/37f34797-6b92-40d5-911a-90c40e9ffaaa
iOS requires Apple Developer Portal setup before building. See Prerequisites for details.
[!IMPORTANT] Submit your Family Controls distribution approval request now. App Store distribution requires Apple approval per bundle ID — it can take days to weeks and you can't ship without it. Request here (you'll need to submit once per bundle ID — 4 total).
While waiting for approval, use the Family Controls (Development) capability in Xcode instead of the standard "Family Controls" — it's marked "Development only" in Xcode's Signing & Capabilities tab and works without Apple's approval. Development builds with this entitlement are fully functional on device but cannot be submitted to TestFlight or the App Store.
- Features
- Build Compatibility
- Quick Start
- Prerequisites
- Plugin Options
- API Reference
- Full Example: iOS App Blocker
- Platform Notes
- How It Works
- Contributing
Features
- Block specific apps from being used
- Inline app picker - embed the iOS system app picker directly in your UI (like Duolingo)
- Modal app picker - present the system picker as a sheet
- Customizable iOS shield overlay (icon, title, subtitle, button text, colors, blur style)
- Native view for rendering blocked app names/icons (Apple's opaque tokens)
- Temporary unlock with timer
- Auto-relock when unlock period expires (iOS DeviceActivityMonitor extension)
- Notification when blocked app is detected
- Persist blocked apps across app restarts
- Automatic iOS extension target creation via
@bacons/apple-targets - Full Expo Config Plugin - no manual native setup required
Build Compatibility
| Build type | Supported | Notes |
|---|---|---|
| Expo Go | ❌ | Requires custom native modules — not available in Expo Go |
| Development build (expo-dev-client) | ✅ | Fully supported — same setup as production |
| Local build (expo run:ios / expo run:android) | ✅ | Fully supported |
| EAS Build | ✅ | Fully supported — see EAS Build config |
| Production / App Store | ✅ | Fully supported — iOS requires Apple approval first |
This plugin requires a development build or a production build. If you're using Expo Go, you'll need to create a development build first:
npx expo install expo-dev-client
npx expo run:ios --device # or: eas build --profile developmentQuick Start
1. Install
npx expo install expo-app-blocker2. Configure app.json
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.yourapp.id",
"appleTeamId": "YOUR_TEAM_ID"
},
"plugins": [
["expo-app-blocker", {
"ios": {
"appGroup": "group.com.yourapp.blocker",
"shield": {
"title": "Hold on!",
"subtitle": "{appName} is blocked.",
"primaryButtonLabel": "Earn Free Time",
"primaryButtonColor": "#fb6107",
"backgroundColor": "#f6f6f6",
"backgroundBlurStyle": "systemThickMaterialLight"
}
}
}]
]
}
}3. Use in your app
import {
requestPermissions,
setBlockConfiguration,
clearAllBlocks,
temporaryUnlock,
FamilyActivityPickerView,
type FamilyActivityPickerSelectionEvent,
} from 'expo-app-blocker';
function AppBlockerScreen() {
const [selectionData, setSelectionData] = useState('');
// 1. Request Screen Time permission (call once)
const handleAuth = async () => {
const { allGranted } = await requestPermissions();
if (!allGranted) console.log('User denied Screen Time access');
};
// 2. Handle selection changes from the inline picker
const handleSelectionChange = async (event: FamilyActivityPickerSelectionEvent) => {
setSelectionData(event.selectionData);
if (event.items.length > 0) {
// Apply blocks — shields appear immediately on selected apps
await setBlockConfiguration({ blockedItems: event.items, isActive: true });
} else {
clearAllBlocks();
}
};
return (
<View>
{/* Inline app picker — renders the iOS system picker in your UI */}
<FamilyActivityPickerView
initialSelection={selectionData}
onSelectionChange={handleSelectionChange}
theme="light"
style={{ height: 500 }}
/>
{/* Unlock apps temporarily (e.g. after completing a quiz) */}
<Button
title="Unlock for 15 minutes"
onPress={() => temporaryUnlock(15)}
/>
</View>
);
}4. Build and run
npx expo prebuild --clean
npx expo run:ios --device # physical device required for Screen Time APIs
npx expo run:android # Android works on emulatorPrerequisites
Apple Developer Portal (iOS)
Full step-by-step guide: docs/APPLE_DEVELOPER_SETUP.md
Register 4 App IDs with Family Controls and App Groups capabilities:
com.yourapp.id(main app)com.yourapp.id.DeviceActivityMonitorcom.yourapp.id.ShieldActioncom.yourapp.id.ShieldConfiguration
Create an App Group:
group.com.yourapp.blocker(or your chosen identifier)Assign the App Group to all 4 App IDs
Request Family Controls capability approval (required for App Store/TestFlight distribution)
- Submit the form once per bundle ID (4 total): developer.apple.com/contact/request/family-controls-distribution
- While waiting for approval: use Family Controls (Development) in Xcode's Signing & Capabilities tab — fully functional in dev builds, just not distributable
- Incomplete capability setup causes cryptic provisioning errors — make sure all 4 App IDs have Family Controls + App Groups enabled
Android
No special setup required beyond what the config plugin handles automatically.
Plugin Options
| Option | Type | Default | Description |
|---|---|---|---|
| ios.appGroup | string | Required | App Group identifier for shared data |
| ios.shield.title | string | "Hold on!" | Shield overlay title |
| ios.shield.subtitle | string | "{appName} is blocked." | Shield subtitle. {appName} is replaced with the blocked app name |
| ios.shield.primaryButtonLabel | string | "Earn Free Time" | Primary button text |
| ios.shield.secondaryButtonLabel | string\|null | "Not now" | Secondary button text. Set to null to hide |
| ios.shield.primaryButtonColor | string | "#fb6107" | Primary button background color (hex) |
| ios.shield.titleColor | string | "#111111" | Title text color (hex) |
| ios.shield.subtitleColor | string | "#737373" | Subtitle text color (hex) |
| ios.shield.backgroundColor | string\|null | null | Solid background color (hex). e.g. "#f6f6f6" for light, "#1a1a2e" for dark |
| ios.shield.backgroundBlurStyle | string\|null | "systemThickMaterial" | Blur style. See Blur Styles for all options |
| ios.shield.icon | string | SF Symbol | Path to custom shield icon PNG (e.g. "./assets/shield-icon.png") |
| android.notificationTitle | string | "App Blocked" | Notification title |
| android.notificationText | string | "{appName} is blocked." | Notification text |
Blur Styles
| Category | Values |
|---|---|
| Adaptive (auto light/dark) | systemUltraThinMaterial, systemThinMaterial, systemMaterial, systemThickMaterial, systemChromeMaterial |
| Light only | systemUltraThinMaterialLight, systemThinMaterialLight, systemMaterialLight, systemThickMaterialLight, systemChromeMaterialLight |
| Dark only | systemUltraThinMaterialDark, systemThinMaterialDark, systemMaterialDark, systemThickMaterialDark, systemChromeMaterialDark |
| Legacy | regular, prominent, light, dark, extraLight |
Both backgroundColor and backgroundBlurStyle can be combined — the blur renders behind the color.
EAS Build
For EAS Build, declare extensions in app.json for credential management:
{
"extra": {
"eas": {
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"targetName": "DeviceActivityMonitor",
"bundleIdentifier": "com.yourapp.id.DeviceActivityMonitor",
"entitlements": {
"com.apple.developer.family-controls": true,
"com.apple.security.application-groups": ["group.com.yourapp.blocker"]
}
},
{
"targetName": "ShieldAction",
"bundleIdentifier": "com.yourapp.id.ShieldAction",
"entitlements": {
"com.apple.developer.family-controls": true,
"com.apple.security.application-groups": ["group.com.yourapp.blocker"]
}
},
{
"targetName": "ShieldConfiguration",
"bundleIdentifier": "com.yourapp.id.ShieldConfiguration",
"entitlements": {
"com.apple.developer.family-controls": true,
"com.apple.security.application-groups": ["group.com.yourapp.blocker"]
}
}
]
}
}
}
}
}
}API Reference
Permissions
import { getPermissionStatus, requestPermissions } from 'expo-app-blocker';
// Check current status
const status = await getPermissionStatus();
// { allGranted: boolean, details: AndroidPermissions | IOSPermissions }
// Request permissions (iOS: Screen Time authorization, Android: no-op)
const result = await requestPermissions();Android: Permission Settings
import { openOverlaySettings, openUsageStatsSettings } from 'expo-app-blocker';
openOverlaySettings(); // "Display over other apps"
openUsageStatsSettings(); // "Usage access"Android: App Blocking
import { setBlockedApps, getBlockedApps, getInstalledApps } from 'expo-app-blocker';
const apps = await getInstalledApps();
// [{ packageName: 'com.instagram.android', name: 'Instagram' }, ...]
setBlockedApps(['com.instagram.android', 'com.google.android.youtube']);
const blocked = getBlockedApps(); // ['com.instagram.android', ...]Android: Monitoring
import { startMonitoring, stopMonitoring } from 'expo-app-blocker';
startMonitoring(); // Start foreground service (auto-started on init)
stopMonitoring(); // Stop monitoringiOS: App Selection
Two ways to let users pick which apps to block:
Inline Picker (Recommended)
Embeds Apple's FamilyActivityPicker directly in your UI — the same approach Duolingo and other Screen Time apps use. The picker renders as a searchable native view with app and category lists.
import { FamilyActivityPickerView, setBlockConfiguration } from 'expo-app-blocker';
<FamilyActivityPickerView
initialSelection={selectionData}
onSelectionChange={async (event) => {
setSelectionData(event.selectionData); // save for next mount
await setBlockConfiguration({ blockedItems: event.items, isActive: true });
}}
theme="light"
style={{ height: 500 }}
/>Props:
| Prop | Type | Default | Description |
|---|---|---|---|
| initialSelection | string | — | Base64-encoded selection from a previous selectionData. Restores prior selection on mount |
| onSelectionChange | (event) => void | — | Fires each time the user toggles an app or category |
| theme | "light" \| "dark" \| "system" | "system" | Forces the picker's color scheme |
| style | ViewStyle | { minHeight: 400 } | Set an explicit height for best results |
onSelectionChange event:
| Field | Type | Description |
|---|---|---|
| items | IOSBlockedItem[] | Selected apps/categories — pass directly to setBlockConfiguration() |
| totalApps | number | Number of individual apps selected |
| totalCategories | number | Number of categories selected |
| selectionData | string | Base64 string — save and pass back as initialSelection |
Modal Picker
Opens the system picker as a modal sheet. Returns items on "Done", rejects on cancel.
import { presentFamilyActivityPicker } from 'expo-app-blocker';
try {
const items = await presentFamilyActivityPicker();
await setBlockConfiguration({ blockedItems: items, isActive: true });
} catch (e) {
// User cancelled
}iOS: Block Configuration
import { setBlockConfiguration, getBlockConfiguration, clearAllBlocks } from 'expo-app-blocker';
// Apply blocks (shields appear on selected apps)
await setBlockConfiguration({
blockedItems: items, // from picker
isActive: true,
});
// Get current configuration
const config = getBlockConfiguration();
// Remove all blocks
clearAllBlocks();iOS: Temporary Unlock
import {
temporaryUnlock,
isTemporarilyUnlocked,
getRemainingUnlockTime,
relockApps,
} from 'expo-app-blocker';
// Unlock for N minutes (removes shields temporarily)
const result = await temporaryUnlock(15);
// { unlocked: boolean, expiresAt: number }
const unlocked = isTemporarilyUnlocked(); // boolean
const seconds = getRemainingUnlockTime(); // seconds remaining
await relockApps(); // re-lock immediatelyiOS: Shield Button Events
When a user taps the primary button on the shield overlay, your app receives an event:
import { addPendingUnlockListener, checkAndClearPendingUnlock } from 'expo-app-blocker';
// Check if button was tapped while app was closed
const hasPending = checkAndClearPendingUnlock();
// Listen for real-time taps
const subscription = addPendingUnlockListener(() => {
// Navigate to your unlock/quiz screen
router.push('/unlock');
});
// Clean up
subscription?.remove();iOS: Blocked Apps List
Renders blocked app tokens with their real names and icons using Apple's native Label view. Since iOS tokens are opaque, this is the only way to display app names/icons outside the picker.
import { BlockedAppsNativeList } from 'expo-app-blocker';
<BlockedAppsNativeList
items={blockedItems}
selectionData={selectionBase64}
style={{ minHeight: 200 }}
/>Props:
| Prop | Type | Default | Description |
|---|---|---|---|
| items | IOSBlockedItem[] | Required | Blocked items from picker |
| selectionData | string | — | Base64 selection for accurate rendering |
| style | ViewStyle | { minHeight: 50 } | Standard style |
Full Example: iOS App Blocker
A complete example showing permissions, inline picker, blocking, and temporary unlock:
import { useState, useEffect, useCallback } from 'react';
import { View, Text, TouchableOpacity, Platform, StyleSheet } from 'react-native';
import {
getPermissionStatus,
requestPermissions,
setBlockConfiguration,
getBlockConfiguration,
clearAllBlocks,
temporaryUnlock,
isTemporarilyUnlocked,
getRemainingUnlockTime,
relockApps,
addPendingUnlockListener,
checkAndClearPendingUnlock,
FamilyActivityPickerView,
type PermissionStatus,
type IOSBlockedItem,
type FamilyActivityPickerSelectionEvent,
} from 'expo-app-blocker';
export default function BlockerScreen() {
const [permissions, setPermissions] = useState<PermissionStatus | null>(null);
const [blockedApps, setBlockedApps] = useState<IOSBlockedItem[]>([]);
const [selectionData, setSelectionData] = useState('');
const [unlocked, setUnlocked] = useState(false);
// Load permissions and existing blocks on mount
useEffect(() => {
getPermissionStatus().then(setPermissions);
const config = getBlockConfiguration();
if (config?.blockedItems?.length) {
setBlockedApps(config.blockedItems);
}
}, []);
// Listen for shield button taps
useEffect(() => {
if (checkAndClearPendingUnlock()) {
// User tapped shield button while app was closed
}
const sub = addPendingUnlockListener(() => {
// User tapped shield button — show your unlock UI
});
return () => sub?.remove();
}, []);
// Handle inline picker selection
const handleSelectionChange = async (event: FamilyActivityPickerSelectionEvent) => {
const items = event.items.filter(i => i.type !== 'summary');
setBlockedApps(items);
setSelectionData(event.selectionData);
if (items.length > 0) {
await setBlockConfiguration({ blockedItems: items, isActive: true });
} else {
clearAllBlocks();
}
};
if (Platform.OS !== 'ios') return null;
return (
<View style={styles.container}>
{/* Permission request */}
{!permissions?.allGranted && (
<TouchableOpacity
style={styles.button}
onPress={async () => {
const result = await requestPermissions();
setPermissions(result);
}}
>
<Text style={styles.buttonText}>Enable Screen Time</Text>
</TouchableOpacity>
)}
{/* Inline app picker */}
{permissions?.allGranted && (
<View style={styles.pickerContainer}>
<FamilyActivityPickerView
initialSelection={selectionData}
onSelectionChange={handleSelectionChange}
theme="light"
style={{ height: 500 }}
/>
</View>
)}
{/* Actions */}
{blockedApps.length > 0 && (
<View style={styles.actions}>
<Text>{blockedApps.length} apps blocked</Text>
<TouchableOpacity
style={styles.button}
onPress={async () => {
await temporaryUnlock(15);
setUnlocked(true);
}}
>
<Text style={styles.buttonText}>Unlock 15 min</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPress={() => { clearAllBlocks(); setBlockedApps([]); }}
>
<Text style={styles.buttonText}>Clear All</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
pickerContainer: { borderRadius: 16, overflow: 'hidden', borderWidth: 1, borderColor: '#e8e8e8' },
actions: { marginTop: 16, gap: 12 },
button: { backgroundColor: '#fb6107', padding: 16, borderRadius: 12, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});Platform Notes
iOS Limitations
- Physical device required - Screen Time APIs don't work in the simulator
- App tokens are opaque - You cannot extract app names/bundle IDs from tokens. Use
BlockedAppsNativeListorFamilyActivityPickerViewto display them - FamilyActivityPicker is required - No API to enumerate installed apps on iOS
- Shield customization is limited - Only icon, title, subtitle, button labels, and colors can be changed. No custom views, fonts, or animations
- Cannot open apps from shield - Use notifications as a workaround to redirect users to your app
- Permission status may lag - After a user grants or revokes Screen Time access outside your app, the status may not update until the app is restarted. Re-check on app foreground
- Picker may crash on large categories - The native
FamilyActivityPickercan crash when scrolling through very large app categories. Consider providing fallback UI (e.g. a retry button) if this affects your users
Android Limitations
- ~500ms detection delay - The foreground polling interval means a blocked app is briefly visible before the overlay appears
- Overlay permission requires manual grant - Users must enable "Display over other apps" in system settings
- Usage access permission requires manual grant - Users must enable in system settings
- OEM battery optimizations - Some manufacturers (Xiaomi, Samsung, etc.) may kill the foreground service. Users may need to disable battery optimization for your app
Android Permissions (auto-added by config plugin)
| Permission | Purpose |
|---|---|
| SYSTEM_ALERT_WINDOW | Display blocking overlay |
| FOREGROUND_SERVICE | Run monitoring service |
| FOREGROUND_SERVICE_SPECIAL_USE | Required for Android 14+ |
| PACKAGE_USAGE_STATS | Detect foreground app |
| RECEIVE_BOOT_COMPLETED | Auto-start service on boot |
| POST_NOTIFICATIONS | Show blocked app notifications |
How It Works
Android Flow
ExpoAppBlockerModulestartsAppBlockerServiceas a foreground service- Service polls
UsageStatsManagerevery 500ms to detect the foreground app - If the foreground app is in the blocked list:
- A full-screen overlay covers the screen
- A notification is sent with a deep link to your app
- Your app is brought to the foreground
- Blocked apps are persisted in SharedPreferences
iOS Flow
- User authorizes Screen Time via
requestPermissions() - User selects apps to block — inline via
<FamilyActivityPickerView>or modal viapresentFamilyActivityPicker() setBlockConfiguration()applies shields viaManagedSettingsStore- When a blocked app is opened, iOS shows the shield overlay (customized via config plugin)
- When the user taps the shield button,
ShieldActionExtensionsends a notification - Your app receives the event via
addPendingUnlockListener()and can navigate to an unlock flow temporaryUnlock()removes shields for a durationDeviceActivityMonitorextension re-applies shields when the unlock period expires
Contributing
Contributions are welcome! See CONTRIBUTING.md for setup instructions, project structure, and guidelines.
License
MIT
