From 0980cdcb5b6e5cfb683a3cb02496bb8018cdd5ab Mon Sep 17 00:00:00 2001 From: Arthur Levoyer Date: Sat, 31 May 2025 19:46:50 +0200 Subject: [PATCH 01/21] =?UTF-8?q?Enhance=20README.md=20with=20detailed=20e?= =?UTF-8?q?xplanations=20of=20Screen=20Time=20APIs,=20ins=E2=80=A6=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhance README.md with detailed explanations of Screen Time APIs, installation instructions, and examples for using the FamilyControl, ShieldConfiguration, and ActivityMonitor APIs. * Update README.md to enhance structure with a comprehensive table of contents, clarify API usage, and improve installation instructions for Expo and bare React Native projects. * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Refine README.md for clarity and consistency, including capitalization adjustments, improved phrasing, and additional import statements for better code examples. * Update README.md to include a direct link to Apple's Screen Time APIs video and add a blank line for improved readability. * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix formatting issues in README.md by correcting code block syntax and adding blank lines for improved readability. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README.md | 458 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 373 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index d2b3077..d1fc27d 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,204 @@ +# react-native-device-activity + [![Test Status](https://github.com/Kingstinct/react-native-device-activity/actions/workflows/test.yml/badge.svg)](https://github.com/Kingstinct/react-native-device-activity/actions/workflows/test.yml) [![Latest version on NPM](https://img.shields.io/npm/v/react-native-device-activity)](https://www.npmjs.com/package/react-native-device-activity) [![Downloads on NPM](https://img.shields.io/npm/dt/react-native-device-activity)](https://www.npmjs.com/package/react-native-device-activity) [![Discord](https://dcbadge.vercel.app/api/server/hrgnETpsJA?style=flat)](https://discord.gg/hrgnETpsJA) -# react-native-device-activity +React Native wrapper for Apple's Screen Time, Device Activity, and Family Controls APIs. -Provides direct access to Apples Screen Time, Device Activity and Shielding APIs. +⚠️ **Important**: These APIs require [special approval and entitlements from Apple](https://github.com/Kingstinct/react-native-device-activity#family-controls-distribution-entitlement-requires-approval-from-apple). Request this approval as early as possible in your development process. -⚠️ Before planning and starting using these APIs it is highly recommended to familiarize yourself with the [special approval and entitlements required](https://github.com/Kingstinct/react-native-device-activity#family-controls-distribution-entitlement-requires-approval-from-apple). +## Table of Contents -Please note that it only supports iOS (and requires iOS 15 or higher) and requires a Custom Dev Client to work with Expo. For Android I'd probably look into [UsageStats](https://developer.android.com/reference/android/app/usage/UsageStats), which seems provide more granularity. +- [Apple's Screen Time APIs Explained](https://developer.apple.com/videos/play/wwdc2021/10123/) + - [FamilyControl API](#familycontrol-api) + - [ShieldConfiguration API](#shieldconfiguration-api) + - [ShieldAction API](#shieldaction-api) + - [ActivityMonitor API](#activitymonitor-api) +- [Installation in managed Expo projects](#installation-in-managed-expo-projects) + - [Some Notes](#some-notes) + - [Data model](#data-model) +- [Installation in bare React Native projects](#installation-in-bare-react-native-projects) +- [Family Controls (distribution) entitlement requires approval from Apple](#family-controls-distribution-entitlement-requires-approval-from-apple) +- [Basic Example: Event Tracking Approach](#basic-example-event-tracking-approach) +- [Select Apps to track](#select-apps-to-track) +- [Time tracking](#time-tracking) +- [Block the shield](#block-the-shield) +- [Alternative Example: Blocking Apps for a Time Slot](#alternative-example-blocking-apps-for-a-time-slot) + - [Key Concepts Explained](#key-concepts-explained) +- [API Reference](#api-reference-the-list-is-not-exhaustive-yet-please-refer-to-the-typescript-types-for-the-full-list) + - [Components](#components) + - [Hooks](#hooks) + - [Functions](#functions) +- [Contributing](#contributing) +- [Weird behaviors ⚠️](#weird-behaviors-) +- [Troubleshooting 📱](#troubleshooting-) -# Examples & Use Cases +## Apple's Screen Time APIs Explained -## Handle permissions +_(See [WWDC21](https://www.youtube.com/watch?v=DKH0cw9LhtM) for official details.)_ -To block apps, you need to request Screen Time permissions. Note that some features (for example, events) may still trigger without permissions; however, this behavior is not guaranteed. +Note: Depending on your use case, you might not need all the APIs hence not all the new bundle identifier and capabilities are required. Below is a quick overview of the APIs available. -```TypeScript -import React, { useEffect } from 'react'; -import * as ReactNativeDeviceActivity from "react-native-device-activity"; -import React, { useEffect } from 'react'; +### FamilyControl API -useEffect(() => { - ReactNativeDeviceActivity.requestAuthorization(); -}, []) +The FamilyControl API allows your app to access Screen Time data and manage restrictions on apps and websites. -You can also revoke permissions: +**What it does**: Provides access to selection and monitoring of app/website usage +**Example**: Selecting which apps (e.g., Instagram, TikTok) to monitor or block -```TypeScript +### ShieldConfiguration API + +Defines the visual appearance and text shown when users attempt to access blocked content. + +**What it does**: Customizes the blocking screen UI +**Example**: + +```typescript +const shieldConfig = { + title: "Time for a Break!", + subtitle: "These apps are unavailable until midnight.", + primaryButtonLabel: "OK", + iconSystemName: "moon.stars.fill", +}; +``` + +### ShieldAction API + +Defines what happens when users interact with shield buttons. + +**What it does**: Controls behavior when users tap buttons on the shield +**Example**: + +```typescript +const shieldActions = { + primary: { + behavior: "close", // Just close the shield when OK is tapped + }, +}; +``` + +### ActivityMonitor API + +Schedules and manages when restrictions should be applied or removed. This is what will activate the shield when your app is killed. + +**What it does**: Monitors device activity against schedules and thresholds +**Example**: + +```typescript +// Block social media from 7PM to midnight daily +ReactNativeDeviceActivity.startMonitoring( + "evening_block", + { + intervalStart: { hour: 19, minute: 0 }, + intervalEnd: { hour: 23, minute: 59 }, + repeats: true, + }, + [], +); +``` + +## Installation in managed Expo projects + +1. Install the package: + + ```bash + npm install react-native-device-activity + # or + yarn add react-native-device-activity + ``` + +2. Configure the Expo plugin in your `app.json` or `app.config.js`: + + ```json + "plugins": [ + [ + "expo-build-properties", + { + "ios": { + "deploymentTarget": "15.1" + }, + }, + ], + [ + "react-native-device-activity", + { + "appleTeamId": "", + "appGroup": "group." + } + ] + ], + ``` + +3. Generate the native projects: + + ```bash + npx expo prebuild --platform ios + ``` + +4. Verify Xcode Targets: After running prebuild, open the `ios` directory in Xcode (`open ios/YourProject.xcworkspace`). Check that you have the following targets in addition to your main app target: + +- `ActivityMonitorExtension` +- `ShieldAction` +- `ShieldConfiguration` + +### Some Notes + +- It's not possible to 100% know which familyActivitySelection an event being handled is triggered for in the context of the Shield UI/actions. We try to make a best guess here - prioritizing apps/websites in an activitySelection over categories, and smaller activitySelections over larger ones (i.e. "Instagram" over "Instagram + Facebook" over "Social Media Apps"). This means that if you display a shield specific for the Instagram selection that will take precedence over the less specific shields. +- When determining which familyActivitySelectionId that should be used, it will only look for familyActivitySelectionIds that are contained in any of the currently monitored activity names (i.e. if familyActivitySelectionId is "social-media-apps" it will only trigger if there is an activity name that contains "social-media-apps"). This might be a limitation for some implementations, it would probably be nice to make this configurable. + +### Data model + +Almost all the functionality is built around persisting configuration as well as event history to UserDefaults. + +- familyActivitySelectionId mapping. This makes it possible for us to tie a familyActivitySelection token to an id that we can reuse and refer to at a later stage. +- Triggers. This includes configuring shield UI/actions as well as sending web requests or notifications from the Swift background side, in the context of the device activity monitor process. Prefixed like actions*for*${goalId} in userDefaults. This is how we do blocking of apps, updates to shield UI/actions etc. +- Event history. Contains information of which events have been triggered and when. Prefixed like events\_${goalId} in userDefaults. This can be useful for tracking time spent. +- ShieldIds. To reduce the storage strain on userDefaults shields are referenced with shieldIds. + +## Installation in bare React Native projects + +For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing. + +### Add the package to your npm dependencies + +```bash +npm install react-native-device-activity +``` + +### Configure for iOS + +Run `npx pod-install` after installing the npm package. + +## Family Controls (distribution) entitlement requires approval from Apple + +As early as possible you want to [request approval from Apple](https://developer.apple.com/contact/request/family-controls-distribution), since it can take time to get approved. + +Note that until you have approval for all bundleIdentifiers you want to use, you are stuck with local development builds in XCode. i.e., you can't even build an Expo Dev Client. + +For every base bundleIdentifier you need approval for 4 bundleIdentifiers (when leveraging all native extensions that is, you can potentially just use the Shield-related ones if you have no need to listen to the events, or similarly just use the ActivityMonitor if you do not need control over the Shield UI): + +- `com.your-bundleIdentifier` +- `com.your-bundleIdentifier.ActivityMonitor` +- `com.your-bundleIdentifier.ShieldAction` +- `com.your-bundleIdentifier.ShieldConfiguration` + +Once you've gotten approval you need to manually add the "Family Controls (Distribution)" under Additional Capabilities for each of the bundleIdentifiers on [developer.apple.com](https://developer.apple.com/account/resources/identifiers/list) mentioned above. If you use Expo/EAS this has to be done only once, and after that provisioning will be handled automatically. + +⚠️ If you don't do all the above, you will run into a lot of strange provisioning errors. + +## Basic Example: Event Tracking Approach + +Here's another example that focuses on tracking app usage with time thresholds: + +````typescript import * as ReactNativeDeviceActivity from "react-native-device-activity"; ReactNativeDeviceActivity.revokeAuthorization(); +``` + ## Select Apps to track For most use cases you need to get an activitySelection from the user, which is a token representing the apps the user wants to track, block or whitelist. This can be done by presenting the native view: @@ -66,7 +232,7 @@ const DeviceActivityPicker = () => { Some things worth noting here: - This is a SwiftUI view, which is prone to crashing, especially when browsing larger categories of apps or searching for apps. It's recommended to provide a fallback view (positioned behind the SwiftUI view) that allows the user to know what's happening and reload the view and tailor that to your app's design and UX. -The activitySelection tokens can be particularly large (especially if you use includeEntireCategory flag), so you probably want to reference them through a familyActivitySelectionId instead of always passing the string token around. Most functions in this library accept a familyActivitySelectionId as well as the familyActivitySelection token directly. +- The activitySelection tokens can be particularly large (especially if you use includeEntireCategory flag), so you probably want to reference them through a familyActivitySelectionId instead of always passing the string token around. Most functions in this library accept a familyActivitySelectionId as well as the familyActivitySelection token directly. ## Time tracking @@ -116,7 +282,7 @@ const events = ReactNativeDeviceActivityModule.getEvents(); Some things worth noting here: -Depending on your use case (if you need different schedules for different days, for example) you might need multiple monitors. There's a hard limit on 20 monitors at the same time. Study the [DateComponents](https://developer.apple.com/documentation/foundation/datecomponents) object to model this to your use case. +Depending on your use case (if you need different schedules for different days, for example) you might need multiple monitors. There's a hard limit on 20 monitors at the same time. Study the [DateComponents](https://developer.apple.com/documentation/foundation/datecomponents) object to model this to your use case. ## Block the shield @@ -210,90 +376,207 @@ ReactNativeDeviceActivity.updateShield( ) ``` -# Installation in managed Expo projects - -For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release. - -The package requires native code, which includes a custom app target. Currently it requires targeting iOS 15 or higher, so populate app.json/app.config.json as follows: - -``` -"plugins": [ - [ - "expo-build-properties", - { - "ios": { - "deploymentTarget": "15.1" - }, - }, - ], - [ - "react-native-device-activity", - { - "appleTeamId": "", - "appGroup": "group.", - } - ] - ], -``` - -The Swift files for the iOS target will be copied to your local `/targets` directory. You might want to add it to your .gitignore (or if you have other targets in there, you might want to specifically add the three targets added by this library). - -For Expo to be able to automatically handle provisioning you need to specify extra.eas.build.experimental.ios.appExtensions in your app.json/app.config.ts [as seen here](https://github.com/Intentional-Digital/react-native-device-activity/blob/main/example/app.json#L57). - -## Customize native code - -You can potentially modify the targets manually, although you risk the library and your app code diverging. If you want to disable the automatic copying of the targets, you can set `copyToTargetFolder` to `false` in the plugin configuration [as seen here](https://github.com/Intentional-Digital/react-native-device-activity/blob/main/example/app.json#L53). - -## Some notes +## Alternative Example: Blocking Apps for a Time Slot -- It's not possible to 100% know which familyActivitySelection an event being handled is triggered for in the context of the Shield UI/actions. We try to make the best guess here, prioritizing apps/websites in an activitySelection over categories, and smaller activitySelections over larger ones (i.e. "Instagram" over "Instagram + Facebook" over "Social Media Apps"). This means that if you display a shield specific for the Instagram selection that will take precedence over the less specific shields. -- When determining which familyActivitySelectionId that should be used it will only look for familyActivitySelectionIds that are contained in any of the currently monitored activity names (i.e. if familyActivitySelectionId is "social-media-apps" it will only trigger if there is an activity name that contains "social-media-apps"). This might be a limitation for some implementations, it would probably be nice to make this configurable. +This example shows how to implement a complete app blocking system on a given interval. The main principle is that you're configuring these apps to be blocked with FamilyControl API and then schedule when the shield should be shown with ActivityMonitor API. You're customizing the shield UI and actions with ShieldConfiguration and ShieldAction APIs. -## Data model +```typescript +import { useEffect, useState } from 'react'; +import { Alert, View, Button } from 'react-native'; +import * as ReactNativeDeviceActivity from 'react-native-device-activity'; -Almost all the functionality is built around persisting configuration as well as event history to UserDefaults. - -- familyActivitySelectionId mapping. This makes it possible for us to tie a familyActivitySelection token to an id that we can reuse and refer to at a later stage. -- Triggers. This includes configuring shield UI/actions as well as sending web requests or notifications from the Swift background side, in the context of the device activity monitor process. Prefixed like actions*for*${goalId} in userDefaults. This is how we do blocking of apps, updates to shield UI/actions etc. -- Event history. Contains information of which events have been triggered and when. Prefixed like events\_${goalId} in userDefaults. This can be useful for tracking time spent. -- ShieldIds. To reduce the storage strain on userDefaults shields are referenced with shieldIds. - -# Installation in bare React Native projects +// Constants for identifying your selections, shields and scheduled activities +const SELECTION_ID = "evening_block_selection"; +const SHIELD_CONFIG_ID = "evening_shield_config"; +const ACTIVITY_NAME = "evening_block"; -For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing. - -### Add the package to your npm dependencies +const AppBlocker = () => { + // Step 1: Request authorization when component mounts + useEffect(() => { + ReactNativeDeviceActivity.requestAuthorization().then((status) => { + console.info("Authorization status:", status); + // You need to handle various status outcomes: + // "authorized", "denied", "notDetermined", etc. + }); + }, []); + + // Step 2: Manage the selection state of apps/websites to block + const [currentFamilyActivitySelection, setCurrentFamilyActivitySelection] = + useState(null); + + // Step 3: Handle selection changes from the native selection UI + const handleSelectionChange = (event) => { + // The selection is a serialized string containing the user's app selections + setCurrentFamilyActivitySelection(event.nativeEvent.familyActivitySelection); + }; + + // Step 4: Save the selection for use by the extension + const saveSelection = () => { + if (!currentFamilyActivitySelection) { + Alert.alert("Error", "Please select at least one app to block"); + return; + } + + // Store the selection with a consistent ID so the extension can access it + ReactNativeDeviceActivity.setFamilyActivitySelectionId({ + id: SELECTION_ID, + familyActivitySelection: currentFamilyActivitySelection + }); + + // Now configure the blocking schedule + configureBlocking(); + }; + + // Step 5: Configure the shield (blocking screen UI) + const configureBlocking = () => { + // Define how the blocking screen looks + const shieldConfig = { + title: "App Blocked", + subtitle: "This app is currently unavailable", + primaryButtonLabel: "OK", + iconSystemName: "moon.stars.fill" // SF Symbols icon name + }; + + // Define what happens when users interact with the shield + const shieldActions = { + primary: { + behavior: "close" // Just close the shield when OK is tapped + } + }; + + // Apply the shield configuration + ReactNativeDeviceActivity.updateShield(shieldConfig, shieldActions); + + // Configure what happens when the scheduled interval begins + ReactNativeDeviceActivity.configureActions({ + activityName: ACTIVITY_NAME, + callbackName: "intervalDidStart", // Called when the scheduled time begins + actions: [{ + type: "blockSelection", + familyActivitySelectionId: SELECTION_ID, // The stored selection ID + shieldId: SHIELD_CONFIG_ID // The shield to show when blocked + }] + }); + + // Configure what happens when the scheduled interval ends + ReactNativeDeviceActivity.configureActions({ + activityName: ACTIVITY_NAME, + callbackName: "intervalDidEnd", // Called when the scheduled time ends + actions: [{ + type: "unblockSelection", + familyActivitySelectionId: SELECTION_ID // Unblock the same selection + }] + }); + + // Start the monitoring schedule + startScheduledBlocking(); + }; + + // Step 6: Define and start the blocking schedule + const startScheduledBlocking = async () => { + try { + // Define when blocking should occur (7 PM to midnight daily) + const schedule = { + intervalStart: { hour: 19, minute: 0 }, // 7:00 PM + intervalEnd: { hour: 23, minute: 59 }, // 11:59 PM + repeats: true // Repeat this schedule daily + // Optional: warningTime: { minutes: 5 } // Warn user 5 minutes before blocking starts + }; + + // For testing, you might want a shorter interval that starts soon: + const testSchedule = { + intervalStart: { + hour: new Date().getHours(), + minute: new Date().getMinutes(), + second: (new Date().getSeconds() + 10) % 60, // +10 seconds from now + }, + intervalEnd: { + hour: new Date().getHours() + Math.floor((new Date().getMinutes() + 5) / 60), + minute: (new Date().getMinutes() + 5) % 60, // +5 minutes from start + }, + repeats: false, // One-time test + }; + + // Start monitoring with the schedule + // The empty array is for event monitors (optional) + await ReactNativeDeviceActivity.startMonitoring( + ACTIVITY_NAME, + schedule, // Use testSchedule for testing + [] + ); + + Alert.alert("Success", "Blocking schedule has been set up!"); + } catch (error) { + console.error("Failed to start scheduled blocking:", error); + Alert.alert("Error", "Failed to set up blocking schedule"); + } + }; -``` -npm install react-native-device-activity + return ( + + {/* Native selection view for choosing apps to block */} + + + {/* Save button */} + + + Picker Views + + + setPickerNative(false)} + showNavigationBar + onSelectionChange={( + event: NativeSyntheticEvent, + ) => { + console.log("sheet view selection changed", event.nativeEvent); + }} + familyActivitySelectionId="picker-native" + onReload={() => { + setPickerNative(false); + setTimeout(() => setPickerNative(true), 100); + }} + /> + setPickerCustomModal(false)} + showNavigationBar={false} + onSelectionChange={( + event: NativeSyntheticEvent, + ) => { + console.log("selection view changed", event.nativeEvent); + }} + familyActivitySelectionId="picker-custom-modal" + onReload={() => { + setPickerCustomModal(false); + setTimeout(() => setPickerCustomModal(true), 100); + }} + /> ); } diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift index 3fe03eb..c1ccf0f 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift @@ -213,7 +213,8 @@ class NativeEventObserver { _: CFDictionary? ) in if let observer = observer, let name = name { - let nativeObserver = Unmanaged.fromOpaque(observer).takeUnretainedValue() + let nativeObserver = Unmanaged.fromOpaque(observer) + .takeUnretainedValue() guard let module = nativeObserver.module else { return } @@ -828,7 +829,8 @@ public class ReactNativeDeviceActivityModule: Module { // view definition: Prop, Events. View(ReactNativeDeviceActivityView.self) { Events( - "onSelectionChange" + "onSelectionChange", + "onDismissRequest" ) // Defines a setter for the `name` prop. Prop("familyActivitySelection") { (view: ReactNativeDeviceActivityView, prop: String) in @@ -844,7 +846,18 @@ public class ReactNativeDeviceActivityModule: Module { Prop("headerText") { (view: ReactNativeDeviceActivityView, prop: String?) in view.model.headerText = prop } + + Prop("showNavigationBar") { (view: ReactNativeDeviceActivityView, prop: Bool?) in + let enabled = prop ?? false + view.model.showNavigationBar = enabled + // When using the native .familyActivityPicker() modifier, set the + // hosting controller background so the presented sheet inherits it + // instead of falling through to the window's white default. + view.contentView.view.backgroundColor = enabled ? .systemGroupedBackground : .clear + view.backgroundColor = enabled ? .systemGroupedBackground : .clear + } } + } } @@ -854,7 +867,8 @@ public class ReactNativeDeviceActivityViewPersistedModule: Module { Name("ReactNativeDeviceActivityViewPersistedModule") View(ReactNativeDeviceActivityViewPersisted.self) { Events( - "onSelectionChange" + "onSelectionChange", + "onDismissRequest" ) // Defines a setter for the `name` prop. Prop("familyActivitySelectionId") { @@ -894,6 +908,13 @@ public class ReactNativeDeviceActivityViewPersistedModule: Module { Prop("headerText") { (view: ReactNativeDeviceActivityViewPersisted, prop: String?) in view.model.headerText = prop } + + Prop("showNavigationBar") { (view: ReactNativeDeviceActivityViewPersisted, prop: Bool?) in + let enabled = prop ?? false + view.model.showNavigationBar = enabled + view.contentView.view.backgroundColor = enabled ? .systemGroupedBackground : .clear + view.backgroundColor = enabled ? .systemGroupedBackground : .clear + } } } } diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift index 675dfcf..897a322 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift @@ -31,6 +31,10 @@ class ReactNativeDeviceActivityView: ExpoView { self.addSubview(contentView.view) + model.onDismissRequest = { [weak self] in + self?.onDismissRequest([:]) + } + model.$activitySelection.debounce(for: .seconds(0.1), scheduler: RunLoop.main).sink { selection in if selection != self.previousSelection { @@ -45,7 +49,37 @@ class ReactNativeDeviceActivityView: ExpoView { contentView.view.frame = bounds } + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + // Establish a proper UIKit parent–child VC relationship so that + // SwiftUI presentation modifiers (like .familyActivityPicker) can + // walk the VC hierarchy and present sheets. + if contentView.parent == nil, let parentVC = parentViewController { + parentVC.addChild(contentView) + contentView.didMove(toParent: parentVC) + } + } else { + if contentView.parent != nil { + contentView.willMove(toParent: nil) + contentView.removeFromParent() + } + } + } + + private var parentViewController: UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let vc = next as? UIViewController { + return vc + } + responder = next + } + return nil + } + let onSelectionChange = EventDispatcher() + let onDismissRequest = EventDispatcher() var previousSelection: FamilyActivitySelection? diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift index ea3cde0..f37a2ce 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift @@ -31,6 +31,10 @@ class ReactNativeDeviceActivityViewPersisted: ExpoView { self.addSubview(contentView.view) + model.onDismissRequest = { [weak self] in + self?.onDismissRequest([:]) + } + model.$activitySelection.debounce(for: .seconds(0.1), scheduler: RunLoop.main).sink { selection in if selection != self.previousSelection { @@ -45,7 +49,34 @@ class ReactNativeDeviceActivityViewPersisted: ExpoView { contentView.view.frame = bounds } + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + if contentView.parent == nil, let parentVC = parentViewController { + parentVC.addChild(contentView) + contentView.didMove(toParent: parentVC) + } + } else { + if contentView.parent != nil { + contentView.willMove(toParent: nil) + contentView.removeFromParent() + } + } + } + + private var parentViewController: UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let vc = next as? UIViewController { + return vc + } + responder = next + } + return nil + } + let onSelectionChange = EventDispatcher() + let onDismissRequest = EventDispatcher() var previousSelection: FamilyActivitySelection? diff --git a/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift b/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift index b175a1b..7a82d7e 100644 --- a/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift +++ b/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift @@ -23,6 +23,10 @@ class ScreenTimeSelectAppsModel: ObservableObject { @Published public var includeEntireCategory: Bool? + @Published public var showNavigationBar: Bool = false + + var onDismissRequest: (() -> Void)? + init() {} } @@ -30,21 +34,139 @@ class ScreenTimeSelectAppsModel: ObservableObject { struct ActivityPicker: View { @ObservedObject var model: ScreenTimeSelectAppsModel + /// Local state used by the `.familyActivityPicker` modifier to drive + /// its native sheet presentation. + @State private var isPickerPresented = false + + private var resolvedHeaderText: String? { + let trimmed = model.headerText?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + } + + private var resolvedFooterText: String? { + let trimmed = model.footerText?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + } + var body: some View { - if #available(iOS 16.0, *) { + if model.showNavigationBar { + // Use the `.familyActivityPicker(isPresented:selection:)` **modifier** + // instead of the inline `FamilyActivityPicker` view. The modifier + // presents the picker as a native sheet with Cancel/Done in the nav bar. + nativeSheetPresentation + } else { + pickerContent + } + } + + // MARK: - Native sheet (modifier-based) presentation + + @ViewBuilder + private var nativeSheetPresentation: some View { + if #available(iOS 16.0, *), resolvedHeaderText != nil || resolvedFooterText != nil { + Color.clear + .familyActivityPicker( + headerText: resolvedHeaderText, + footerText: resolvedFooterText, + isPresented: $isPickerPresented, + selection: $model.activitySelection + ) + .onAppear { isPickerPresented = true } + .onChange(of: isPickerPresented) { presented in + if !presented { model.onDismissRequest?() } + } + .background(PresentedSheetBackgroundFixer()) + } else { + Color.clear + .familyActivityPicker( + isPresented: $isPickerPresented, + selection: $model.activitySelection + ) + .onAppear { isPickerPresented = true } + .onChange(of: isPickerPresented) { presented in + if !presented { model.onDismissRequest?() } + } + .background(PresentedSheetBackgroundFixer()) + } + } + + // MARK: - Inline (embedded) picker + + @ViewBuilder + private var pickerContent: some View { + if #available(iOS 16.0, *), resolvedHeaderText != nil || resolvedFooterText != nil { FamilyActivityPicker( - headerText: model.headerText, - footerText: model.footerText, + headerText: resolvedHeaderText, + footerText: resolvedFooterText, selection: $model.activitySelection ) - .allowsHitTesting(false) - .background(Color.clear) } else { FamilyActivityPicker( selection: $model.activitySelection ) - .allowsHitTesting(false) - .background(Color.clear) + } + } +} + +// MARK: - Sheet background fix + +/// Finds the presented picker sheet's view hierarchy and sets the background +/// to `systemGroupedBackground` so the empty area below the list matches +/// the rest of the sheet. Uses a VC representable that observes when our +/// hosting controller presents a child. +@available(iOS 15.0, *) +struct PresentedSheetBackgroundFixer: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> SheetBackgroundFixerController { + SheetBackgroundFixerController() + } + + func updateUIViewController(_ uiViewController: SheetBackgroundFixerController, context: Context) {} +} + +@available(iOS 15.0, *) +class SheetBackgroundFixerController: UIViewController { + private var observation: NSKeyValueObservation? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + startObserving() + } + + private func startObserving() { + // Walk up to find the VC that will present the picker sheet. + var candidate: UIViewController? = self + while let c = candidate { + // Observe `presentedViewController` so we catch it the moment the + // picker sheet appears. + observation = c.observe( + \.presentedViewController, options: [.new] + ) { [weak self] vc, _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self?.fixPresentedBackground(from: vc) + } + } + // Also try immediately in case it's already presented. + fixPresentedBackground(from: c) + if c.presentedViewController != nil || c.parent == nil { break } + candidate = c.parent + } + } + + private func fixPresentedBackground(from vc: UIViewController) { + guard let presented = vc.presentedViewController else { return } + applySystemBackground(to: presented.view) + for child in presented.children { + applySystemBackground(to: child.view) + } + } + + private func applySystemBackground(to view: UIView) { + view.backgroundColor = .systemGroupedBackground + // The picker nests views; walk a few levels deep. + for sub in view.subviews { + if sub.backgroundColor == .white || sub.backgroundColor == .systemBackground { + sub.backgroundColor = .systemGroupedBackground + } } } } diff --git a/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.ios.tsx b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.ios.tsx new file mode 100644 index 0000000..79f6d67 --- /dev/null +++ b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.ios.tsx @@ -0,0 +1,21 @@ +import { requireNativeViewManager } from "expo-modules-core"; +import * as React from "react"; + +import { + DeviceActivitySelectionSheetViewProps, + DeviceActivitySelectionViewProps, +} from "./ReactNativeDeviceActivity.types"; + +type NativeSheetViewProps = DeviceActivitySelectionViewProps & { + showNavigationBar: boolean; + onDismissRequest?: DeviceActivitySelectionSheetViewProps["onDismissRequest"]; +}; + +const NativeView: React.ComponentType = + requireNativeViewManager("ReactNativeDeviceActivity"); + +export default function DeviceActivitySelectionSheetView( + props: DeviceActivitySelectionSheetViewProps, +) { + return ; +} diff --git a/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.tsx b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.tsx new file mode 100644 index 0000000..694d8b4 --- /dev/null +++ b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; +import { View } from "react-native"; + +import { DeviceActivitySelectionSheetViewProps } from "./ReactNativeDeviceActivity.types"; + +export default function DeviceActivitySelectionSheetView({ + style, + children, +}: DeviceActivitySelectionSheetViewProps) { + return {children}; +} diff --git a/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.ios.tsx b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.ios.tsx new file mode 100644 index 0000000..5a4c094 --- /dev/null +++ b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.ios.tsx @@ -0,0 +1,22 @@ +import { requireNativeViewManager } from "expo-modules-core"; +import * as React from "react"; + +import { + DeviceActivitySelectionSheetViewPersistedProps, + DeviceActivitySelectionViewPersistedProps, +} from "./ReactNativeDeviceActivity.types"; + +type NativeSheetViewPersistedProps = DeviceActivitySelectionViewPersistedProps & { + showNavigationBar: boolean; + onDismissRequest?: + DeviceActivitySelectionSheetViewPersistedProps["onDismissRequest"]; +}; + +const NativeView: React.ComponentType = + requireNativeViewManager("ReactNativeDeviceActivityViewPersistedModule"); + +export default function DeviceActivitySelectionSheetViewPersisted( + props: DeviceActivitySelectionSheetViewPersistedProps, +) { + return ; +} diff --git a/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.tsx b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.tsx new file mode 100644 index 0000000..6d56026 --- /dev/null +++ b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; +import { View } from "react-native"; + +import { DeviceActivitySelectionSheetViewPersistedProps } from "./ReactNativeDeviceActivity.types"; + +export default function DeviceActivitySelectionSheetViewPersisted({ + style, + children, +}: DeviceActivitySelectionSheetViewPersistedProps) { + return {children}; +} diff --git a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts index 1fae790..5ae9bcf 100644 --- a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts +++ b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts @@ -46,6 +46,13 @@ export type DeviceActivitySelectionViewProps = PropsWithChildren<{ footerText?: string | null; }>; +export type DeviceActivitySelectionSheetViewProps = DeviceActivitySelectionViewProps & { + /** + * Called when the user taps Cancel or Done in the native sheet navigation bar. + */ + onDismissRequest?: (event: NativeSyntheticEvent>) => void; +}; + export type DeviceActivitySelectionViewPersistedProps = PropsWithChildren<{ style?: StyleProp; onSelectionChange?: ( @@ -61,6 +68,14 @@ export type DeviceActivitySelectionViewPersistedProps = PropsWithChildren<{ includeEntireCategory?: boolean; }>; +export type DeviceActivitySelectionSheetViewPersistedProps = + DeviceActivitySelectionViewPersistedProps & { + /** + * Called when the user taps Cancel or Done in the native sheet navigation bar. + */ + onDismissRequest?: (event: NativeSyntheticEvent>) => void; + }; + /** * @link https://developer.apple.com/documentation/foundation/datecomponents */ diff --git a/packages/react-native-device-activity/src/index.test.ts b/packages/react-native-device-activity/src/index.test.ts index db8d511..b32e1b2 100644 --- a/packages/react-native-device-activity/src/index.test.ts +++ b/packages/react-native-device-activity/src/index.test.ts @@ -1,6 +1,14 @@ // todo: skipping for now describe("test", () => { + test("Should export sheet picker views", () => { + jest.isolateModules(() => { + const module = require("./"); + expect(module.DeviceActivitySelectionSheetView).toBeDefined(); + expect(module.DeviceActivitySelectionSheetViewPersisted).toBeDefined(); + }); + }); + test("Should call stopMonitoring", () => { const mockStopMonitoring = jest.fn(); jest.mock("./ReactNativeDeviceActivityModule", () => ({ diff --git a/packages/react-native-device-activity/src/index.ts b/packages/react-native-device-activity/src/index.ts index 938f02d..0e01eaa 100644 --- a/packages/react-native-device-activity/src/index.ts +++ b/packages/react-native-device-activity/src/index.ts @@ -2,6 +2,8 @@ import { EventEmitter, EventSubscription } from "expo-modules-core"; import { useCallback, useEffect, useState } from "react"; import { Platform } from "react-native"; +import DeviceActivitySelectionSheetView from "./DeviceActivitySelectionSheetView"; +import DeviceActivitySelectionSheetViewPersisted from "./DeviceActivitySelectionSheetViewPersisted"; import DeviceActivitySelectionView from "./DeviceActivitySelectionView"; import DeviceActivitySelectionViewPersisted from "./DeviceActivitySelectionViewPersisted"; import { @@ -18,6 +20,8 @@ import { DeviceActivityEventRaw, DeviceActivityMonitorEventPayload, DeviceActivitySchedule, + DeviceActivitySelectionSheetViewPersistedProps, + DeviceActivitySelectionSheetViewProps, DeviceActivitySelectionViewPersistedProps, DeviceActivitySelectionViewProps, EventListenerMap, @@ -649,9 +653,16 @@ export function isAvailable(): boolean { ); } -export { DeviceActivitySelectionView, DeviceActivitySelectionViewPersisted }; +export { + DeviceActivitySelectionSheetView, + DeviceActivitySelectionSheetViewPersisted, + DeviceActivitySelectionView, + DeviceActivitySelectionViewPersisted, +}; export type { + DeviceActivitySelectionSheetViewProps as ReactNativeDeviceActivitySheetViewProps, + DeviceActivitySelectionSheetViewPersistedProps as ReactNativeDeviceActivitySheetViewPersistedProps, DeviceActivitySelectionViewProps as ReactNativeDeviceActivityViewProps, DeviceActivitySelectionViewPersistedProps as ReactNativeDeviceActivityViewPersistedProps, }; From 50b83ec0ef4921b2d8c5e7274e3304f44800cbc4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:27:36 +0100 Subject: [PATCH 15/21] Replace deprecated dcbadge.vercel.app with dcbadge.limes.pink (#87) * Initial plan * Fix broken Discord badge by replacing dcbadge.vercel.app with dcbadge.limes.pink Co-authored-by: robertherber <1467411+robertherber@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robertherber <1467411+robertherber@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b67d4c3..5c15a61 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Test Status](https://github.com/Kingstinct/react-native-device-activity/actions/workflows/test.yml/badge.svg)](https://github.com/Kingstinct/react-native-device-activity/actions/workflows/test.yml) [![Latest version on NPM](https://img.shields.io/npm/v/react-native-device-activity)](https://www.npmjs.com/package/react-native-device-activity) [![Downloads on NPM](https://img.shields.io/npm/dt/react-native-device-activity)](https://www.npmjs.com/package/react-native-device-activity) -[![Discord](https://dcbadge.vercel.app/api/server/hrgnETpsJA?style=flat)](https://discord.gg/hrgnETpsJA) +[![Discord](https://dcbadge.limes.pink/api/server/hrgnETpsJA?style=flat)](https://discord.gg/hrgnETpsJA) React Native wrapper for Apple's Screen Time, Device Activity, and Family Controls APIs. From 44fe314e517b498e37e0beba90c6060e6237c605 Mon Sep 17 00:00:00 2001 From: Luca <100935601+thisislvca@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:49:41 +0100 Subject: [PATCH 16/21] feat: add web content filter policy API for adult/explicit website blocking (#86) * feat: add web content filter policy API with native/action support, docs, and tests * chore: add example web filter controls and fix swiftlint --- README.md | 76 ++++ apps/example/ios/Podfile.lock | 4 +- .../Tests/WebContentFilterPolicyTests.swift | 151 ++++++++ apps/example/screens/AllTheThings.tsx | 78 ++++ bun.lock | 9 +- .../ios/ReactNativeDeviceActivityModule.swift | 20 ++ .../ios/Shared.swift | 335 ++++++++++++++++++ .../src/ReactNativeDeviceActivity.types.ts | 35 ++ .../src/ReactNativeDeviceActivityModule.ts | 3 + .../src/index.test.ts | 82 ++++- .../react-native-device-activity/src/index.ts | 27 ++ 11 files changed, 800 insertions(+), 20 deletions(-) create mode 100644 apps/example/ios/Tests/WebContentFilterPolicyTests.swift diff --git a/README.md b/README.md index 5c15a61..2431a6d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ React Native wrapper for Apple's Screen Time, Device Activity, and Family Contro - [Select Apps to track](#select-apps-to-track) - [Time tracking](#time-tracking) - [Block the shield](#block-the-shield) +- [Web Content Filter Policy](#web-content-filter-policy) - [Alternative Example: Blocking Apps for a Time Slot](#alternative-example-blocking-apps-for-a-time-slot) - [Key Concepts Explained](#key-concepts-explained) - [API Reference](#api-reference-the-list-is-not-exhaustive-yet-please-refer-to-the-typescript-types-for-the-full-list) @@ -428,6 +429,78 @@ ReactNativeDeviceActivity.updateShield( ) ``` +### Web Content Filter Policy + +Use this when you want to block web content without changing your app/category shield behavior, for example enabling adult/explicit site filtering during school/work hours while keeping existing app rules untouched. Under the hood this maps to Apple's [`WebContentSettings`](https://developer.apple.com/documentation/managedsettings/webcontentsettings) on [`ManagedSettingsStore.webContent`](https://developer.apple.com/documentation/managedsettings/managedsettingsstore/webcontent), including filter modes for [`auto(_:except:)`](https://developer.apple.com/documentation/managedsettings/webcontentsettings/filterpolicy/auto%28_%3Aexcept%3A%29), [`specific(_:)`](https://developer.apple.com/documentation/managedsettings/webcontentsettings/filterpolicy/specific%28_%3A%29), and [`all(except:)`](https://developer.apple.com/documentation/managedsettings/webcontentsettings/filterpolicy/all%28except%3A%29). + +```typescript +import * as ReactNativeDeviceActivity from "react-native-device-activity"; + +// Block adult/explicit websites using Apple's automatic filter. +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "auto", +}); +``` + +You can also provide explicit blocked and exception domains: + +```typescript +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "auto", + domains: ["example-adult-site.com"], + exceptDomains: ["example.com"], +}); +``` + +`specific` and `all` are also supported: + +```typescript +// Block only the listed domains +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "specific", + domains: ["example.com", "another-example.com"], +}); + +// Block all websites except the listed domains +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "all", + exceptDomains: ["example.com"], +}); +``` + +To clear web-content filtering: + +```typescript +ReactNativeDeviceActivity.clearWebContentFilterPolicy(); +``` + +To check whether a filter policy is currently active: + +```typescript +const isActive = ReactNativeDeviceActivity.isWebContentFilterPolicyActive(); +``` + +You can configure this from background actions as well: + +```typescript +ReactNativeDeviceActivity.configureActions({ + activityName: "school-hours", + callbackName: "intervalDidStart", + actions: [ + { + type: "setWebContentFilterPolicy", + policy: { + type: "auto", + }, + }, + ], +}); +``` + +Notes: +- Apple currently limits `domains` and `exceptDomains` to 50 entries each (depending on selected filter policy). +- Invalid or malformed policy input throws (for example: unknown `type`, missing required arrays, empty domains, or lists over the limit). + ## Alternative Example: Blocking Apps for a Time Slot This example shows how to implement a complete app blocking system on a given interval. The main principle is that you're configuring these apps to be blocked with FamilyControl API and then schedule when the shield should be shown with ActivityMonitor API. You're customizing the shield UI and actions with ShieldConfiguration and ShieldAction APIs. @@ -623,6 +696,9 @@ For a complete implementation, see the [example app](https://github.com/Kingstin | `setFamilyActivitySelectionId` | `{ id: string, familyActivitySelection: string }` | void | Store a family activity selection with given ID | | `updateShield` | `config`: ShieldConfiguration
`actions`: ShieldActions | void | Update the shield UI and actions | | `configureActions` | `{ activityName: string, callbackName: string, actions: Action[] }` | void | Configure actions for monitor events | +| `setWebContentFilterPolicy` | `policy`: WebContentFilterPolicyInput
`triggeredBy?`: string | void | Apply web filtering policy (`auto`, `specific`, `all`, `none`) | +| `clearWebContentFilterPolicy` | `triggeredBy?`: string | void | Clear only the web content filter policy | +| `isWebContentFilterPolicyActive` | None | boolean | Return whether web content filter policy is active | | `getEvents` | None | DeviceActivityEvent[] | Get history of triggered events | | `userDefaultsSet` | `key`: string
`value`: any | void | Store value in shared UserDefaults | | `userDefaultsGet` | `key`: string | any | Retrieve value from shared UserDefaults | diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 40c1caf..d36bfc5 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -1601,7 +1601,7 @@ PODS: - React-logger - React-perflogger - React-utils (= 0.76.9) - - ReactNativeDeviceActivity (0.4.30): + - ReactNativeDeviceActivity (0.5.3): - ExpoModulesCore - RNVectorIcons (10.2.0): - DoubleConversion @@ -1947,7 +1947,7 @@ SPEC CHECKSUMS: React-utils: ed818f19ab445000d6b5c4efa9d462449326cc9f ReactCodegen: 2a46abb2e345dc8efaff0a724f5f8639230eb974 ReactCommon: 300d8d9c5cb1a6cd79a67cf5d8f91e4d477195f9 - ReactNativeDeviceActivity: 1017837e25b5b5247b85d7a542b5176b27ee85d3 + ReactNativeDeviceActivity: 49c0579e2cbb940f4366b917d9e17491a70c5bd7 RNVectorIcons: 4330d8f8f8f4184f436e0c08ae9950431ffe466e SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SwiftLint: 365bcd9ffc83d0deb874e833556d82549919d6cd diff --git a/apps/example/ios/Tests/WebContentFilterPolicyTests.swift b/apps/example/ios/Tests/WebContentFilterPolicyTests.swift new file mode 100644 index 0000000..c039adb --- /dev/null +++ b/apps/example/ios/Tests/WebContentFilterPolicyTests.swift @@ -0,0 +1,151 @@ +import ManagedSettings +import XCTest + +@available(iOS 15.0, *) +class WebContentFilterPolicyTests: XCTestCase { + override func setUp() { + super.setUp() + clearWebContentFilterPolicy(triggeredBy: "WebContentFilterPolicyTests.setUp") + } + + override func tearDown() { + clearWebContentFilterPolicy(triggeredBy: "WebContentFilterPolicyTests.tearDown") + super.tearDown() + } + + func testAutoPolicyParsesDomainsAndExceptions() throws { + let parsed = try parseWebContentFilterPolicyInput( + policyInput: [ + "type": "auto", + "domains": [ + "https://adult.example.com/path" + ], + "exceptDomains": [ + "safe.example.com" + ] + ] + ) + + switch parsed.policy { + case .auto(let domains, except: let exceptDomains): + XCTAssertEqual(domains.compactMap(\.domain).sorted(), ["adult.example.com"]) + XCTAssertEqual(exceptDomains.compactMap(\.domain).sorted(), ["safe.example.com"]) + default: + XCTFail("Expected auto policy") + } + } + + func testSpecificPolicyRejectsMoreThanFiftyDomains() { + // 51 unique domains should fail (Apple limit is 50) + let domains = (1...51).map { index in + return "blocked-\(index).example.com" + } + + XCTAssertThrowsError( + try parseWebContentFilterPolicyInput( + policyInput: [ + "type": "specific", + "domains": domains + ] + ) + ) + } + + func testSpecificPolicyAllowsExactlyFiftyDomains() throws { + let domains = (1...50).map { index in + return "blocked-\(index).example.com" + } + + let parsed = try parseWebContentFilterPolicyInput( + policyInput: [ + "type": "specific", + "domains": domains + ] + ) + + switch parsed.policy { + case .specific(let parsedDomains): + XCTAssertEqual(parsedDomains.count, 50) + default: + XCTFail("Expected specific policy") + } + } + + func testAllPolicyRejectsMoreThanFiftyExceptions() { + // 51 unique domains should fail (Apple limit is 50) + let domains = (1...51).map { index in + return "allowed-\(index).example.com" + } + + XCTAssertThrowsError( + try parseWebContentFilterPolicyInput( + policyInput: [ + "type": "all", + "exceptDomains": domains + ] + ) + ) + } + + func testAutoPolicyNormalizesQueryAndFragmentDomains() throws { + let parsed = try parseWebContentFilterPolicyInput( + policyInput: [ + "type": "auto", + "domains": ["example.com?foo=1"], + "exceptDomains": ["safe.example.com#top"] + ] + ) + + switch parsed.policy { + case .auto(let domains, except: let exceptDomains): + XCTAssertEqual(domains.compactMap(\.domain).sorted(), ["example.com"]) + XCTAssertEqual(exceptDomains.compactMap(\.domain).sorted(), ["safe.example.com"]) + default: + XCTFail("Expected auto policy") + } + } + + func testClearPolicyDeactivatesFilter() throws { + try setWebContentFilterPolicy( + policyInput: [ + "type": "auto" + ], + triggeredBy: "WebContentFilterPolicyTests.testClearPolicyDeactivatesFilter" + ) + + XCTAssertTrue(isWebContentFilterPolicyActive()) + + clearWebContentFilterPolicy( + triggeredBy: "WebContentFilterPolicyTests.testClearPolicyDeactivatesFilter" + ) + + XCTAssertFalse(isWebContentFilterPolicyActive()) + } + + func testExecuteGenericActionAppliesAndClearsPolicy() { + executeGenericAction( + action: [ + "type": "setWebContentFilterPolicy", + "policy": [ + "type": "auto", + "domains": ["adult.example.com"], + "exceptDomains": ["safe.example.com"] + ] + ], + placeholders: [:], + triggeredBy: "WebContentFilterPolicyTests.testExecuteGenericActionAppliesAndClearsPolicy" + ) + + XCTAssertTrue(isWebContentFilterPolicyActive()) + + executeGenericAction( + action: [ + "type": "clearWebContentFilterPolicy" + ], + placeholders: [:], + triggeredBy: "WebContentFilterPolicyTests.testExecuteGenericActionAppliesAndClearsPolicy" + ) + + XCTAssertFalse(isWebContentFilterPolicyActive()) + } +} diff --git a/apps/example/screens/AllTheThings.tsx b/apps/example/screens/AllTheThings.tsx index ab6fd20..d031d99 100644 --- a/apps/example/screens/AllTheThings.tsx +++ b/apps/example/screens/AllTheThings.tsx @@ -179,6 +179,8 @@ export function AllTheThings() { const [isShieldActive, setIsShieldActive] = useState(false); const [isShieldActiveWithSelection, setIsShieldActiveWithSelection] = useState(false); + const [isWebContentFilterPolicyActive, setIsWebContentFilterPolicyActive] = + useState(false); const refreshIsShieldActive = useCallback(() => { setIsShieldActive(ReactNativeDeviceActivity.isShieldActive()); @@ -200,6 +202,17 @@ export function AllTheThings() { } }, []); + const refreshWebContentFilterPolicyActive = useCallback(() => { + setIsWebContentFilterPolicyActive( + ReactNativeDeviceActivity.isWebContentFilterPolicyActive(), + ); + }, []); + + useEffect(() => { + refreshIsShieldActive(); + refreshWebContentFilterPolicyActive(); + }, [refreshIsShieldActive, refreshWebContentFilterPolicyActive]); + const [pickerVisible, setPickerVisible] = useState(false); return ( @@ -221,6 +234,10 @@ export function AllTheThings() { Shielding current selection: {isShieldActiveWithSelection ? "✅" : "❌"} + + Web content filter active: + {isWebContentFilterPolicyActive ? "✅" : "❌"} + + + + +
Activities {activities.map((activity) => ( diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift index e223bf9..8aee1f2 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift @@ -570,13 +570,18 @@ public class ReactNativeDeviceActivityModule: Module { try await ac.requestAuthorization( for: forIndividualOrChild == "child" ? .child : .individual) } else { - let errorMessage = "iOS 16.0 or later is required to request authorization." - logger.log("⚠️ \(errorMessage)") - throw NSError( - domain: "FamilyControls", - code: 9999, - userInfo: [NSLocalizedDescriptionKey: errorMessage] - ) + // Deprecated iOS 15 API - uses completion handler + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + ac.requestAuthorization { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + logger.log("❌ Failed to request authorization: \(error.localizedDescription, privacy: .public)") + continuation.resume(throwing: error) + } + } + } } } From c97333b63a83058c1ab9ece515dd9a22e5704496 Mon Sep 17 00:00:00 2001 From: Ryan Iguchi Date: Tue, 17 Feb 2026 14:51:41 +0000 Subject: [PATCH 18/21] chore: update version --- packages/react-native-device-activity/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-device-activity/package.json b/packages/react-native-device-activity/package.json index 48b4090..e7e69a3 100644 --- a/packages/react-native-device-activity/package.json +++ b/packages/react-native-device-activity/package.json @@ -1,6 +1,6 @@ { "name": "react-native-device-activity", - "version": "0.5.3", + "version": "0.6.0", "description": "Provides access to Apples DeviceActivity API", "main": "build/index.js", "types": "build/index.d.ts", From f943fd715dadd55d3fe057bcbb959d19e2605da3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 02:25:33 +0100 Subject: [PATCH 19/21] Fix iconTint ignored in ShieldConfiguration (#91) * Initial plan * Fix iconTint not being applied to shield icon Co-authored-by: robertherber <1467411+robertherber@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robertherber <1467411+robertherber@users.noreply.github.com> --- .../ShieldConfiguration/ShieldConfigurationExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-device-activity/targets/ShieldConfiguration/ShieldConfigurationExtension.swift b/packages/react-native-device-activity/targets/ShieldConfiguration/ShieldConfigurationExtension.swift index 48d628e..63fca8e 100644 --- a/packages/react-native-device-activity/targets/ShieldConfiguration/ShieldConfigurationExtension.swift +++ b/packages/react-native-device-activity/targets/ShieldConfiguration/ShieldConfigurationExtension.swift @@ -61,7 +61,7 @@ func resolveIcon(dict: [String: Any]) -> UIImage? { } if let iconTint = getColor(color: dict["iconTint"] as? [String: Double]) { - image?.withTintColor(iconTint) + image = image?.withTintColor(iconTint, renderingMode: .alwaysOriginal) } return image From 15656ae1de7c736f981b7c53174c165c2277901b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:26:30 +0000 Subject: [PATCH 20/21] chore: update version --- packages/react-native-device-activity/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-device-activity/package.json b/packages/react-native-device-activity/package.json index e7e69a3..0d3fbb5 100644 --- a/packages/react-native-device-activity/package.json +++ b/packages/react-native-device-activity/package.json @@ -1,6 +1,6 @@ { "name": "react-native-device-activity", - "version": "0.6.0", + "version": "0.6.1", "description": "Provides access to Apples DeviceActivity API", "main": "build/index.js", "types": "build/index.d.ts", From ae6099656281cfde1bfada5cae500b14f944995f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:30:37 +0100 Subject: [PATCH 21/21] docs: clarify Apple entitlement form must be submitted per bundle identifier (#93) * Initial plan * docs: clarify that Apple entitlement form must be filled out for each bundle identifier Co-authored-by: robertherber <1467411+robertherber@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robertherber <1467411+robertherber@users.noreply.github.com> --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2431a6d..fc29e12 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ For every base bundleIdentifier you need approval for 4 bundleIdentifiers (when - `com.your-bundleIdentifier.ShieldAction` - `com.your-bundleIdentifier.ShieldConfiguration` +⚠️ **You need to fill out the Apple request form once for each of these bundle identifiers** — that means filling out the form 4 separate times (one per bundle identifier listed above) if you are using all native extensions. + Once you've gotten approval you need to manually add the "Family Controls (Distribution)" under Additional Capabilities for each of the bundleIdentifiers on [developer.apple.com](https://developer.apple.com/account/resources/identifiers/list) mentioned above. If you use Expo/EAS this has to be done only once, and after that provisioning will be handled automatically. ⚠️ If you don't do all the above, you will run into a lot of strange provisioning errors.