npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

expo-app-blocker

v0.1.25

Published

Expo module for cross-platform app blocking. Android: UsageStatsManager + Overlay. iOS: Screen Time API (FamilyControls + ManagedSettings + DeviceActivity).

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

  • 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 development

Quick Start

1. Install

npx expo install expo-app-blocker

2. 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 emulator

Prerequisites

Apple Developer Portal (iOS)

Full step-by-step guide: docs/APPLE_DEVELOPER_SETUP.md

  1. Register 4 App IDs with Family Controls and App Groups capabilities:

    • com.yourapp.id (main app)
    • com.yourapp.id.DeviceActivityMonitor
    • com.yourapp.id.ShieldAction
    • com.yourapp.id.ShieldConfiguration
  2. Create an App Group: group.com.yourapp.blocker (or your chosen identifier)

  3. Assign the App Group to all 4 App IDs

  4. 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 monitoring

iOS: 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 immediately

iOS: 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 BlockedAppsNativeList or FamilyActivityPickerView to 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 FamilyActivityPicker can 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

  1. ExpoAppBlockerModule starts AppBlockerService as a foreground service
  2. Service polls UsageStatsManager every 500ms to detect the foreground app
  3. 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
  4. Blocked apps are persisted in SharedPreferences

iOS Flow

  1. User authorizes Screen Time via requestPermissions()
  2. User selects apps to block — inline via <FamilyActivityPickerView> or modal via presentFamilyActivityPicker()
  3. setBlockConfiguration() applies shields via ManagedSettingsStore
  4. When a blocked app is opened, iOS shows the shield overlay (customized via config plugin)
  5. When the user taps the shield button, ShieldActionExtension sends a notification
  6. Your app receives the event via addPendingUnlockListener() and can navigate to an unlock flow
  7. temporaryUnlock() removes shields for a duration
  8. DeviceActivityMonitor extension re-applies shields when the unlock period expires

Contributing

Contributions are welcome! See CONTRIBUTING.md for setup instructions, project structure, and guidelines.

License

MIT