diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abaed15b..15d9e40b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,9 +19,9 @@ jobs: bun-version: latest - run: bun install - + - run: bun lint - + typecheck: name: Typecheck runs-on: ubuntu-latest @@ -34,7 +34,7 @@ jobs: bun-version: latest - run: bun install - + - run: bun typecheck typecheck-example: @@ -43,7 +43,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v4 - + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -59,7 +59,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v4 - + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -91,7 +91,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v4 - + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -107,7 +107,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v4 - + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -123,7 +123,7 @@ jobs: - run: pod install working-directory: apps/example/ios - + - run: apps/example/ios/Pods/SwiftLint/swiftlint lint swift-test: @@ -166,19 +166,15 @@ jobs: restore-keys: xcode-cache-deriveddata-${{ github.workflow }}- - name: Set up Xcode version - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: Check Swift version run: swift --version - + - run: pod install working-directory: apps/example/ios - run: cp .swiftlint.yml apps/example/ios - - - run: xcodebuild test -workspace reactnativedeviceactivityexample.xcworkspace -scheme Tests -allowProvisioningUpdates -destination "platform=iOS Simulator,OS=latest,name=iPhone 16" - working-directory: apps/example/ios - - - \ No newline at end of file + - run: xcodebuild test -workspace reactnativedeviceactivityexample.xcworkspace -scheme Tests -allowProvisioningUpdates -destination "platform=iOS Simulator,OS=latest,name=iPhone 17" + working-directory: apps/example/ios diff --git a/README.md b/README.md index d2b30777..fc29e129 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,262 @@ +# 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) +[![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. + +⚠️ **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. + +## Table of Contents + +- [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) +- [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) + - [Components](#components) + - [Hooks](#hooks) + - [Functions](#functions) +- [Contributing](#contributing) +- [Weird behaviors ⚠️](#weird-behaviors-) +- [Troubleshooting 📱](#troubleshooting-) + +## Apple's Screen Time APIs Explained + +_(See [WWDC21](https://www.youtube.com/watch?v=DKH0cw9LhtM) for official details.)_ + +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. + +### FamilyControl API + +The FamilyControl API allows your app to access Screen Time data and manage restrictions on apps and websites. + +**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 + +### 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", +}; +``` -# react-native-device-activity +### ShieldAction API -Provides direct access to Apples Screen Time, Device Activity and Shielding APIs. +Defines what happens when users interact with shield buttons. -⚠️ 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). +**What it does**: Controls behavior when users tap buttons on the shield +**Example**: -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. +```typescript +const shieldActions = { + primary: { + behavior: "close", // Just close the shield when OK is tapped + }, +}; +``` -# Examples & Use Cases +### ActivityMonitor API -## Handle permissions +Schedules and manages when restrictions should be applied or removed. This is what will activate the shield when your app is killed. -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. +**What it does**: Monitors device activity against schedules and thresholds +**Example**: -```TypeScript -import React, { useEffect } from 'react'; -import * as ReactNativeDeviceActivity from "react-native-device-activity"; -import React, { useEffect } from 'react'; +```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, + }, + [], +); +``` -useEffect(() => { - ReactNativeDeviceActivity.requestAuthorization(); -}, []) +## Installation in managed Expo projects -You can also revoke permissions: +1. Install the package: -```TypeScript + ```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` + +⚠️ **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. + +## 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 +``` + +### 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 `DeviceActivitySelectionView`. + +#### Presentation options + +The picker now has dedicated components for each presentation style: -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: +`*SelectionView` components take a raw `familyActivitySelection` token. +`*SelectionViewPersisted` components take a `familyActivitySelectionId` and persist/read the token on the native side by ID. + +**Native sheet** -- `DeviceActivitySelectionSheetView` (and persisted variant) uses Apple's `.familyActivityPicker(isPresented:selection:)` flow with native Cancel/Done controls. + +```TypeScript +// The sheet view acts as an invisible anchor. +// The native side presents the iOS sheet and fires onDismissRequest on Cancel/Done. +{pickerVisible && ( + setPickerVisible(false)} + onSelectionChange={handleSelectionChange} + familyActivitySelection={familyActivitySelection} + /> +)} +``` + +**Custom presentation (fallback/customizable)** -- `DeviceActivitySelectionView` (and persisted variant) renders inline. You can embed it directly in your layout or wrap it in a React Native `` for a custom sheet. + +```TypeScript +import { Modal, View } from "react-native"; + + + + + + +``` + +#### Which one should I use? + +- Use `DeviceActivitySelectionSheetView` for a native iOS sheet UX (system Cancel/Done). +- Use `DeviceActivitySelectionView` when you need full control over presentation and a custom crash fallback UI. +- Use the persisted variants when you want to store/reuse selections across screens/sessions or avoid passing very large selection tokens through JS. + +#### Full example ```TypeScript import * as ReactNativeDeviceActivity from "react-native-device-activity"; @@ -66,9 +287,9 @@ 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 +### Time tracking It's worth noting that the Screen Time API is not designed for time tracking out-of-the-box. So you have to set up events with names you can parse as time after they've triggered. @@ -116,9 +337,9 @@ 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 +### Block the shield To block apps, you can do it directly from your code. @@ -210,90 +431,285 @@ ReactNativeDeviceActivity.updateShield( ) ``` -# Installation in managed Expo projects +### Web Content Filter Policy -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. +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). -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: +```typescript +import * as ReactNativeDeviceActivity from "react-native-device-activity"; +// Block adult/explicit websites using Apple's automatic filter. +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "auto", +}); ``` -"plugins": [ - [ - "expo-build-properties", - { - "ios": { - "deploymentTarget": "15.1" - }, - }, - ], - [ - "react-native-device-activity", - { - "appleTeamId": "", - "appGroup": "group.", - } - ] - ], + +You can also provide explicit blocked and exception domains: + +```typescript +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "auto", + domains: ["example-adult-site.com"], + exceptDomains: ["example.com"], +}); ``` -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). +`specific` and `all` are also supported: -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). +```typescript +// Block only the listed domains +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "specific", + domains: ["example.com", "another-example.com"], +}); -## Customize native code +// Block all websites except the listed domains +ReactNativeDeviceActivity.setWebContentFilterPolicy({ + type: "all", + exceptDomains: ["example.com"], +}); +``` -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). +To clear web-content filtering: -## Some notes +```typescript +ReactNativeDeviceActivity.clearWebContentFilterPolicy(); +``` -- 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. +To check whether a filter policy is currently active: -## Data model +```typescript +const isActive = ReactNativeDeviceActivity.isWebContentFilterPolicyActive(); +``` -Almost all the functionality is built around persisting configuration as well as event history to UserDefaults. +You can configure this from background actions as well: -- 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. +```typescript +ReactNativeDeviceActivity.configureActions({ + activityName: "school-hours", + callbackName: "intervalDidStart", + actions: [ + { + type: "setWebContentFilterPolicy", + policy: { + type: "auto", + }, + }, + ], +}); +``` -# Installation in bare React Native projects +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). -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. +## Alternative Example: Blocking Apps for a Time Slot -### Add the package to your npm dependencies +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. -``` -npm install react-native-device-activity +```typescript +import { useEffect, useState } from 'react'; +import { Alert, View, Button } from 'react-native'; +import * as ReactNativeDeviceActivity from 'react-native-device-activity'; + +// 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"; + +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"); + } + }; + + return ( + + {/* Native selection view for choosing apps to block */} + + + {/* Save button */} + + + + + Activities {activities.map((activity) => ( @@ -140,6 +158,22 @@ export function SimpleTab() { > Create Activity + + 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/bun.lock b/bun.lock index 09bd807d..ea4b6e3f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "react-native-device-activity", @@ -855,7 +856,7 @@ "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -2425,8 +2426,6 @@ "expo-module-scripts/babel-preset-expo": ["babel-preset-expo@11.0.15", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-transform-export-namespace-from": "^7.22.11", "@babel/plugin-transform-object-rest-spread": "^7.12.13", "@babel/plugin-transform-parameters": "^7.22.15", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.74.87", "babel-plugin-react-compiler": "0.0.0-experimental-592953e-20240517", "babel-plugin-react-native-web": "~0.19.10", "react-refresh": "^0.14.2" } }, "sha512-rgiMTYwqIPULaO7iZdqyL7aAff9QLOX6OWUtLZBlOrOTreGY1yHah/5+l8MvI6NVc/8Zj5LY4Y5uMSnJIuzTLw=="], - "expo-module-scripts/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "expo-module-scripts/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -2547,6 +2546,8 @@ "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "react-native-vector-icons/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], "react-test-renderer/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], @@ -2599,8 +2600,6 @@ "terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], diff --git a/packages/react-native-device-activity/app.plugin.js b/packages/react-native-device-activity/app.plugin.js index be71b0ed..2abfa4c7 100644 --- a/packages/react-native-device-activity/app.plugin.js +++ b/packages/react-native-device-activity/app.plugin.js @@ -6,7 +6,7 @@ const withCopyTargetFolder = require("./config-plugin/withCopyTargetFolder"); const withEntitlementsPlugin = require("./config-plugin/withEntitlements"); const withInfoPlistAppGroup = require("./config-plugin/withInfoPlistAppGroup"); const withXcodeSettings = require("./config-plugin/withXCodeSettings"); -const pkg = require("../../package.json"); +const pkg = require(process.cwd() + "/package.json"); /** @type {import('@expo/config-plugins').ConfigPlugin<{ appleTeamId?: string; match?: string; appGroup: string; copyToTargetFolder?: boolean }>} */ const withActivityMonitorExtensionPlugin = (config, props) => { diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift index 2572738e..8aee1f2e 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift @@ -193,10 +193,14 @@ func convertToSwiftDateComponents(from dateComponentsFromJS: DateComponentsFromJ class NativeEventObserver { let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() - let observer: UnsafeRawPointer + private weak var module: BaseModule? + private var observer: UnsafeRawPointer? func registerListener(name: String) { let notificationName = name as CFString + guard let observer else { + return + } CFNotificationCenterAddObserver( notificationCenter, observer, @@ -209,9 +213,12 @@ class NativeEventObserver { _: CFDictionary? ) in if let observer = observer, let name = name { - let mySelf = Unmanaged.fromOpaque(observer).takeUnretainedValue() - - mySelf.sendEvent( + let nativeObserver = Unmanaged.fromOpaque(observer) + .takeUnretainedValue() + guard let module = nativeObserver.module else { + return + } + module.sendEvent( "onDeviceActivityMonitorEvent" as String, [ "callbackName": name.rawValue @@ -224,7 +231,8 @@ class NativeEventObserver { } init(module: BaseModule) { - observer = UnsafeRawPointer(Unmanaged.passUnretained(module).toOpaque()) + self.module = module + observer = UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque()) registerListener(name: "intervalDidStart") registerListener(name: "intervalDidEnd") registerListener(name: "eventDidReachThreshold") @@ -232,6 +240,18 @@ class NativeEventObserver { registerListener(name: "intervalWillEndWarning") registerListener(name: "eventWillReachThresholdWarning") } + + func unregister() { + guard let observer else { + return + } + CFNotificationCenterRemoveEveryObserver(notificationCenter, observer) + self.observer = nil + } + + deinit { + unregister() + } } @available(iOS 15.0, *) @@ -277,8 +297,6 @@ public class ReactNativeDeviceActivityModule: Module { ]) let fileManager = FileManager.default - let observer = NativeEventObserver(module: self) - var watchActivitiesHandle: Cancellable? var onDeviceActivityDetectedHandle: Cancellable? @@ -321,7 +339,10 @@ public class ReactNativeDeviceActivityModule: Module { return to.absoluteString } + var observer: NativeEventObserver? + OnStartObserving { + observer = NativeEventObserver(module: self) onDeviceActivityDetectedHandle = AuthorizationCenter.shared.$authorizationStatus.sink { status in self.sendEvent( @@ -341,6 +362,8 @@ public class ReactNativeDeviceActivityModule: Module { } OnStopObserving { + observer?.unregister() + observer = nil watchActivitiesHandle?.cancel() onDeviceActivityDetectedHandle?.cancel() } @@ -500,7 +523,8 @@ public class ReactNativeDeviceActivityModule: Module { during: schedule, events: dictionary ) - logger.log("✅ Succeeded with Starting Monitor Activity: \(activityName.rawValue)") + logger.log( + "✅ Succeeded with Starting Monitor Activity: \(activityName.rawValue, privacy: .public)") } @@ -546,7 +570,18 @@ public class ReactNativeDeviceActivityModule: Module { try await ac.requestAuthorization( for: forIndividualOrChild == "child" ? .child : .individual) } else { - logger.log("⚠️ iOS 16.0 or later is required to request authorization.") + // 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) + } + } + } } } @@ -681,13 +716,17 @@ public class ReactNativeDeviceActivityModule: Module { return isShieldActive() } + Function("isWebContentFilterPolicyActive") { + return isWebContentFilterPolicyActive() + } + Function("blockSelection") { (familyActivitySelection: [String: Any], triggeredBy: String?) in let triggeredBy = triggeredBy ?? "blockSelection called manually" let activitySelection = parseActivitySelectionInput(input: familyActivitySelection) - try blockSelectedApps( + blockSelectedApps( blockSelection: activitySelection, triggeredBy: triggeredBy ) @@ -770,6 +809,22 @@ public class ReactNativeDeviceActivityModule: Module { clearWhitelist() } + Function("setWebContentFilterPolicy") { + (policy: [String: Any], triggeredBy: String?) throws in + let triggeredBy = triggeredBy ?? "setWebContentFilterPolicy called manually" + try setWebContentFilterPolicy( + policyInput: policy, + triggeredBy: triggeredBy + ) + } + + Function("clearWebContentFilterPolicy") { + (triggeredBy: String?) in + clearWebContentFilterPolicy( + triggeredBy: triggeredBy ?? "clearWebContentFilterPolicy called manually" + ) + } + AsyncFunction("revokeAuthorization") { () async throws in let ac = AuthorizationCenter.shared @@ -779,7 +834,8 @@ public class ReactNativeDeviceActivityModule: Module { case .success: continuation.resume() case .failure(let error): - logger.log("❌ Failed to revoke authorization: \(error.localizedDescription)") + logger.log( + "❌ Failed to revoke authorization: \(error.localizedDescription, privacy: .public)") continuation.resume(throwing: error) } } @@ -798,7 +854,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 @@ -808,17 +865,24 @@ public class ReactNativeDeviceActivityModule: Module { } Prop("footerText") { (view: ReactNativeDeviceActivityView, prop: String?) in - view.model.footerText = prop - } 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 } } + } } @@ -828,7 +892,8 @@ public class ReactNativeDeviceActivityViewPersistedModule: Module { Name("ReactNativeDeviceActivityViewPersistedModule") View(ReactNativeDeviceActivityViewPersisted.self) { Events( - "onSelectionChange" + "onSelectionChange", + "onDismissRequest" ) // Defines a setter for the `name` prop. Prop("familyActivitySelectionId") { @@ -868,6 +933,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 675dfcf2..897a3221 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 ea3cde08..f37a2ce0 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 b175a1b4..7a82d7e9 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/ios/Shared.swift b/packages/react-native-device-activity/ios/Shared.swift index 91a50fc7..48394304 100644 --- a/packages/react-native-device-activity/ios/Shared.swift +++ b/packages/react-native-device-activity/ios/Shared.swift @@ -21,6 +21,9 @@ let CURRENT_BLOCKLIST_KEY = "currentBlockedSelection" let CURRENT_WHITELIST_KEY = "currentUnblockedSelection" let IS_BLOCKING_ALL = "isBlockingAll" let FAMILY_ACTIVITY_SELECTION_ID_KEY = "familyActivitySelectionIds" +let WEB_CONTENT_FILTER_POLICY_LAST_UPDATE_KEY = "lastWebContentFilterPolicyUpdate" +let WEB_CONTENT_FILTER_POLICY_LAST_ERROR_KEY = "lastWebContentFilterPolicyError" +let WEB_CONTENT_FILTER_POLICY_MAX_DOMAINS = 50 let appGroup = Bundle.main.object(forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String @@ -146,7 +149,9 @@ func executeGenericAction( triggeredBy: triggeredBy ) } else { - logger.log("No familyActivitySelection found with ID: \(familyActivitySelectionId)") + logger.log( + "No familyActivitySelection found with ID: \(familyActivitySelectionId, privacy: .public)" + ) } } } else if type == "unblockSelection" { @@ -188,6 +193,33 @@ func executeGenericAction( resetBlocks(triggeredBy: triggeredBy) } else if type == "clearWhitelist" { clearWhitelist() + } else if type == "setWebContentFilterPolicy" { + if let policyInput = action["policy"] as? [String: Any] { + do { + try setWebContentFilterPolicy( + policyInput: policyInput, + triggeredBy: triggeredBy + ) + } catch { + setWebContentFilterPolicyErrorMetadata( + triggeredBy: triggeredBy, + error: error, + action: action + ) + logger.error( + "Failed to set web content filter policy in action pipeline: \(error.localizedDescription, privacy: .public)" + ) + } + } else { + setWebContentFilterPolicyErrorMetadata( + triggeredBy: triggeredBy, + error: WebContentFilterPolicyError.missingPolicyPayload, + action: action + ) + logger.error("setWebContentFilterPolicy action is missing policy payload") + } + } else if type == "clearWebContentFilterPolicy" { + clearWebContentFilterPolicy(triggeredBy: triggeredBy) } else if type == "disableBlockAllMode" { disableBlockAllMode(triggeredBy: triggeredBy) } else if type == "openApp" { @@ -238,6 +270,25 @@ func executeGenericAction( // required for it to have time to trigger before process/callback ends sleep(ms: 1000) } + } else if type == "startMonitoring" { + if let activityName = action["activityName"] as? String, + let deviceActivityEvents = action["deviceActivityEvents"] as? [[String: Any]] { + + startMonitoringAction( + activityName: activityName, + deviceActivityEvents: deviceActivityEvents, + intervalStartDelayMs: action["intervalStartDelayMs"] as? Int, + intervalEndDelayMs: action["intervalEndDelayMs"] as? Int, + triggeredBy: triggeredBy + ) + } + } else if type == "stopMonitoring" { + let activityNames = action["activityNames"] as? [String] + + stopMonitoringAction( + activityNames: activityNames, + triggeredBy: triggeredBy + ) } if let sleepAfter = action["sleepAfter"] as? Int { @@ -500,6 +551,311 @@ func clearAllManagedSettingsStoreSettings() { store.clearAllSettings() } +enum WebContentFilterPolicyError: Error, LocalizedError { + case missingPolicyPayload + case missingPolicyType + case invalidPolicyType(String) + case invalidStringArray(fieldName: String) + case missingRequiredDomains(fieldName: String) + case tooManyDomains(fieldName: String, maxCount: Int) + case emptyDomain(fieldName: String) + case invalidDomain(fieldName: String, value: String) + + var errorDescription: String? { + switch self { + case .missingPolicyPayload: + return "WebContentFilterPolicyError: missing required field `policy`." + case .missingPolicyType: + return "WebContentFilterPolicyError: missing required field `type`." + case .invalidPolicyType(let value): + return "WebContentFilterPolicyError: invalid policy type `\(value)`." + case .invalidStringArray(let fieldName): + return + "WebContentFilterPolicyError: field `\(fieldName)` must be an array of strings when provided." + case .missingRequiredDomains(let fieldName): + return + "WebContentFilterPolicyError: field `\(fieldName)` is required and must contain at least one domain." + case .tooManyDomains(let fieldName, let maxCount): + return + "WebContentFilterPolicyError: field `\(fieldName)` can contain at most \(maxCount) domains." + case .emptyDomain(let fieldName): + return + "WebContentFilterPolicyError: field `\(fieldName)` contains an empty domain after normalization." + case .invalidDomain(let fieldName, let value): + return + "WebContentFilterPolicyError: field `\(fieldName)` contains invalid domain `\(value)`." + } + } +} + +@available(iOS 15.0, *) +struct ParsedWebContentFilterPolicy { + let type: String + let policy: WebContentSettings.FilterPolicy + let domains: [String] + let exceptDomains: [String] +} + +func clearWebContentFilterPolicyErrorMetadata() { + userDefaults?.removeObject(forKey: WEB_CONTENT_FILTER_POLICY_LAST_ERROR_KEY) +} + +func setWebContentFilterPolicyErrorMetadata( + triggeredBy: String, + error: Error, + action: [String: Any]? = nil +) { + let policy = action?["policy"] as? [String: Any] + + userDefaults?.set( + [ + "triggeredBy": triggeredBy, + "updatedAt": Date.now.ISO8601Format(), + "error": error.localizedDescription, + "actionType": action?["type"] as? String ?? "unknown", + "policyType": policy?["type"] as? String ?? "unknown" + ], + forKey: WEB_CONTENT_FILTER_POLICY_LAST_ERROR_KEY + ) +} + +func stringArrayFromPolicyInput( + policyInput: [String: Any], + key: String +) throws -> [String]? { + guard let value = policyInput[key] else { + return nil + } + + guard let strings = value as? [String] else { + throw WebContentFilterPolicyError.invalidStringArray(fieldName: key) + } + + return strings +} + +func normalizedWebDomain( + from rawDomain: String, + fieldName: String +) throws -> String { + let trimmed = rawDomain.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw WebContentFilterPolicyError.emptyDomain(fieldName: fieldName) + } + + let lowercased = trimmed.lowercased() + let candidate = lowercased.contains("://") ? lowercased : "https://\(lowercased)" + + guard var normalizedDomain = URLComponents(string: candidate)?.host else { + throw WebContentFilterPolicyError.invalidDomain( + fieldName: fieldName, + value: rawDomain + ) + } + + normalizedDomain = normalizedDomain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + + if normalizedDomain.isEmpty { + throw WebContentFilterPolicyError.emptyDomain(fieldName: fieldName) + } + + if normalizedDomain.contains(" ") { + throw WebContentFilterPolicyError.invalidDomain( + fieldName: fieldName, + value: rawDomain + ) + } + + if normalizedDomain.contains("/") || normalizedDomain.contains("?") + || normalizedDomain.contains("#") { + throw WebContentFilterPolicyError.invalidDomain( + fieldName: fieldName, + value: rawDomain + ) + } + + // We intentionally don't enforce a TLD requirement here. Apple accepts domains + // as strings, and callers may choose internal/single-label hostnames. + return normalizedDomain +} + +@available(iOS 15.0, *) +func parseWebDomains( + rawDomains: [String], + fieldName: String +) throws -> Set { + var normalizedDomains = Set() + + for domain in rawDomains { + normalizedDomains.insert( + try normalizedWebDomain(from: domain, fieldName: fieldName) + ) + } + + // Apply limits after normalization/deduplication so repeated values don't + // count against Apple's 50-domain cap. + if normalizedDomains.count > WEB_CONTENT_FILTER_POLICY_MAX_DOMAINS { + throw WebContentFilterPolicyError.tooManyDomains( + fieldName: fieldName, + maxCount: WEB_CONTENT_FILTER_POLICY_MAX_DOMAINS + ) + } + + var parsedDomains = Set() + for normalizedDomain in normalizedDomains { + parsedDomains.insert(WebDomain(domain: normalizedDomain)) + } + + return parsedDomains +} + +@available(iOS 15.0, *) +func parseRequiredWebDomains( + policyInput: [String: Any], + key: String +) throws -> Set { + guard let rawDomains = try stringArrayFromPolicyInput(policyInput: policyInput, key: key), + !rawDomains.isEmpty + else { + throw WebContentFilterPolicyError.missingRequiredDomains(fieldName: key) + } + + return try parseWebDomains( + rawDomains: rawDomains, + fieldName: key + ) +} + +@available(iOS 15.0, *) +func sortedDomainStrings(domains: Set) -> [String] { + return domains.compactMap(\.domain).sorted() +} + +@available(iOS 15.0, *) +func parseWebContentFilterPolicyInput( + policyInput: [String: Any] +) throws -> ParsedWebContentFilterPolicy { + guard let type = policyInput["type"] as? String else { + throw WebContentFilterPolicyError.missingPolicyType + } + + if type == "none" { + return ParsedWebContentFilterPolicy( + type: type, + policy: .none, + domains: [], + exceptDomains: [] + ) + } + + if type == "auto" { + let rawDomains = try stringArrayFromPolicyInput(policyInput: policyInput, key: "domains") ?? [] + let rawExceptDomains = + try stringArrayFromPolicyInput(policyInput: policyInput, key: "exceptDomains") ?? [] + + let domains = try parseWebDomains( + rawDomains: rawDomains, + fieldName: "domains" + ) + let exceptDomains = try parseWebDomains( + rawDomains: rawExceptDomains, + fieldName: "exceptDomains" + ) + + return ParsedWebContentFilterPolicy( + type: type, + policy: .auto(domains, except: exceptDomains), + domains: sortedDomainStrings(domains: domains), + exceptDomains: sortedDomainStrings(domains: exceptDomains) + ) + } + + if type == "specific" { + let domains = try parseRequiredWebDomains( + policyInput: policyInput, + key: "domains" + ) + return ParsedWebContentFilterPolicy( + type: type, + policy: .specific(domains), + domains: sortedDomainStrings(domains: domains), + exceptDomains: [] + ) + } + + if type == "all" { + let rawExceptDomains = + try stringArrayFromPolicyInput(policyInput: policyInput, key: "exceptDomains") ?? [] + let exceptDomains = try parseWebDomains( + rawDomains: rawExceptDomains, + fieldName: "exceptDomains" + ) + return ParsedWebContentFilterPolicy( + type: type, + policy: .all(except: exceptDomains), + domains: [], + exceptDomains: sortedDomainStrings(domains: exceptDomains) + ) + } + + throw WebContentFilterPolicyError.invalidPolicyType(type) +} + +@available(iOS 15.0, *) +func setWebContentFilterPolicy( + policyInput: [String: Any], + triggeredBy: String +) throws { + let parsedPolicy = try parseWebContentFilterPolicyInput(policyInput: policyInput) + store.webContent.blockedByFilter = parsedPolicy.policy + clearWebContentFilterPolicyErrorMetadata() + + userDefaults?.set( + [ + "triggeredBy": triggeredBy, + "updatedAt": Date.now.ISO8601Format(), + "type": parsedPolicy.type, + "domains": parsedPolicy.domains, + "exceptDomains": parsedPolicy.exceptDomains + ], + forKey: WEB_CONTENT_FILTER_POLICY_LAST_UPDATE_KEY + ) +} + +@available(iOS 15.0, *) +func clearWebContentFilterPolicy( + triggeredBy: String +) { + store.webContent.blockedByFilter = nil + clearWebContentFilterPolicyErrorMetadata() + + userDefaults?.set( + [ + "triggeredBy": triggeredBy, + "updatedAt": Date.now.ISO8601Format(), + "type": "none", + "domains": [], + "exceptDomains": [] + ], + forKey: WEB_CONTENT_FILTER_POLICY_LAST_UPDATE_KEY + ) +} + +@available(iOS 15.0, *) +func isWebContentFilterPolicyActive() -> Bool { + // Intentionally read from the active ManagedSettingsStore instead of UserDefaults + // metadata. This reflects the currently applied policy in the running process. + guard let policy = store.webContent.blockedByFilter else { + return false + } + + if case .none = policy { + return false + } + + return true +} + @available(iOS 15.0, *) func getFamilyActivitySelectionIds() -> [FamilyActivitySelectionWithId] { if let familyActivitySelectionIds = userDefaults?.dictionary( @@ -716,7 +1072,7 @@ func deserializeFamilyActivitySelection(familyActivitySelectionStr: String) do { activitySelection = try decoder.decode(FamilyActivitySelection.self, from: data!) } catch { - logger.log("decode error \(error.localizedDescription)") + logger.log("decode error \(error.localizedDescription, privacy: .public)") } return activitySelection @@ -1284,7 +1640,7 @@ func shouldExecuteAction( ) { if lastTriggeredAt > skipIfAlreadyTriggeredAfter { logger.log( - "skipping executing actions for \(callbackName)\(eventName ?? "") because the last triggered time is after \(skipIfAlreadyTriggeredAfter)" + "skipping executing actions for \(callbackName, privacy: .public)\(eventName ?? "", privacy: .public) because the last triggered time is after \(skipIfAlreadyTriggeredAfter, privacy: .public)" ) return false } @@ -1305,7 +1661,7 @@ func shouldExecuteAction( if lastTriggeredAt >= skipIfAlreadyTriggeredBetweenFromDate && lastTriggeredAt <= skipIfAlreadyTriggeredBetweenToDate { logger.log( - "skipping executing actions for \(callbackName)\(eventName ?? "") because the last triggered time is between \(skipIfAlreadyTriggeredBetweenFromDate) and \(skipIfAlreadyTriggeredBetweenToDate)" + "skipping executing actions for \(callbackName, privacy: .public)\(eventName ?? "", privacy: .public) because the last triggered time is between \(skipIfAlreadyTriggeredBetweenFromDate, privacy: .public) and \(skipIfAlreadyTriggeredBetweenToDate, privacy: .public)" ) return false } @@ -1320,7 +1676,7 @@ func shouldExecuteAction( ) { if lastTriggeredAt < skipIfAlreadyTriggeredBefore { logger.log( - "skipping executing actions for \(callbackName)\(eventName ?? "") because the last triggered time is after \(skipIfAlreadyTriggeredBefore)" + "skipping executing actions for \(callbackName, privacy: .public)\(eventName ?? "", privacy: .public) because the last triggered time is after \(skipIfAlreadyTriggeredBefore, privacy: .public)" ) return false } @@ -1335,7 +1691,7 @@ func shouldExecuteAction( afterDate: skipIfLargerEventRecordedAfter ) { logger.log( - "skipping executing actions for \(eventName) because a larger event triggered after \(skipIfLargerEventRecordedAfter)" + "skipping executing actions for \(eventName, privacy: .public) because a larger event triggered after \(skipIfLargerEventRecordedAfter, privacy: .public)" ) return false } @@ -1351,7 +1707,7 @@ func shouldExecuteAction( Date().timeIntervalSince1970 * 1000 - skipIfAlreadyTriggeredWithinMS if lastTriggeredAt > skipIfAlreadyTriggeredAfter { logger.log( - "skipping executing actions for \(callbackName)\(eventName ?? "") because the last triggered time is after \(skipIfAlreadyTriggeredAfter)" + "skipping executing actions for \(callbackName, privacy: .public)\(eventName ?? "", privacy: .public) because the last triggered time is after \(skipIfAlreadyTriggeredAfter, privacy: .public)" ) return false } @@ -1369,7 +1725,7 @@ func shouldExecuteAction( afterDate: skipIfLargerEventRecordedAfter ) { logger.log( - "skipping executing actions for \(eventName) because a larger event triggered after \(skipIfLargerEventRecordedAfter)" + "skipping executing actions for \(eventName, privacy: .public) because a larger event triggered after \(skipIfLargerEventRecordedAfter, privacy: .public)" ) return false } @@ -1389,7 +1745,7 @@ func shouldExecuteAction( afterDate: skipIfLargerEventRecordedAfter ) { logger.log( - "skipping executing actions for \(eventName) because a larger event triggered after \(skipIfLargerEventRecordedAfter)" + "skipping executing actions for \(eventName, privacy: .public) because a larger event triggered after \(skipIfLargerEventRecordedAfter, privacy: .public)" ) return false } @@ -1423,3 +1779,115 @@ func getAppGroupDirectory() -> URL? { } return nil } + +@available(iOS 15.0, *) +func startMonitoringAction( + activityName: String, + deviceActivityEvents: [[String: Any]], + intervalStartDelayMs: Int?, + intervalEndDelayMs: Int?, + triggeredBy: String +) { + // Create date components for schedule + let now = Date() + let calendar = Calendar.current + + var intervalStart = DateComponents() + var intervalEnd = DateComponents() + + if let startDelayMs = intervalStartDelayMs { + let startDate = now.addingTimeInterval(TimeInterval(startDelayMs) / 1000.0) + intervalStart = calendar.dateComponents([.hour, .minute, .second], from: startDate) + } + + if let endDelayMs = intervalEndDelayMs { + let endDate = now.addingTimeInterval(TimeInterval(endDelayMs) / 1000.0) + intervalEnd = calendar.dateComponents([.hour, .minute, .second], from: endDate) + } else { + // Default to 24 hours from start if not specified + let defaultEndMs = (intervalStartDelayMs ?? 0) + (24 * 60 * 60 * 1000) + let endDate = now.addingTimeInterval(TimeInterval(defaultEndMs) / 1000.0) + intervalEnd = calendar.dateComponents([.hour, .minute, .second], from: endDate) + } + + let schedule = DeviceActivitySchedule( + intervalStart: intervalStart, + intervalEnd: intervalEnd, + repeats: false + ) + + // Create DeviceActivityEvent dictionary + var eventDict: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:] + + for eventData in deviceActivityEvents { + guard let eventName = eventData["eventName"] as? String, + let threshold = eventData["threshold"] as? [String: Any], + let familyActivitySelection = eventData["familyActivitySelection"] as? String + else { + continue + } + + let selection = deserializeFamilyActivitySelection( + familyActivitySelectionStr: familyActivitySelection) + + // Convert threshold to DateComponents + var thresholdComponents = DateComponents() + if let hour = threshold["hour"] as? Int { thresholdComponents.hour = hour } + if let minute = threshold["minute"] as? Int { thresholdComponents.minute = minute } + if let second = threshold["second"] as? Int { thresholdComponents.second = second } + + let includesPastActivity = eventData["includesPastActivity"] as? Bool ?? false + + var event: DeviceActivityEvent + if #available(iOS 17.4, *) { + event = DeviceActivityEvent( + applications: selection.applicationTokens, + categories: selection.categoryTokens, + webDomains: selection.webDomainTokens, + threshold: thresholdComponents, + includesPastActivity: includesPastActivity + ) + } else { + event = DeviceActivityEvent( + applications: selection.applicationTokens, + categories: selection.categoryTokens, + webDomains: selection.webDomainTokens, + threshold: thresholdComponents + ) + } + + eventDict[DeviceActivityEvent.Name(eventName)] = event + } + + let activityName = DeviceActivityName(activityName) + + do { + try center.startMonitoring(activityName, during: schedule, events: eventDict) + logger.log( + "✅ Successfully started monitoring activity: \(activityName.rawValue, privacy: .public) from \(triggeredBy, privacy: .public)" + ) + } catch { + logger.log( + "❌ Failed to start monitoring activity: \(activityName.rawValue, privacy: .public) - \(error.localizedDescription, privacy: .public)" + ) + } +} + +@available(iOS 15.0, *) +func stopMonitoringAction( + activityNames: [String]?, + triggeredBy: String +) { + if let activityNames = activityNames { + // Stop specific activities + let deviceActivityNames = activityNames.map { DeviceActivityName($0) } + center.stopMonitoring(deviceActivityNames) + logger.log( + "✅ Successfully stopped monitoring activities: \(activityNames.joined(separator: ", "), privacy: .public) from \(triggeredBy, privacy: .public)" + ) + } else { + // Stop all monitoring + center.stopMonitoring() + logger.log("✅ Successfully stopped all monitoring from \(triggeredBy, privacy: .public)") + } +} diff --git a/packages/react-native-device-activity/package.json b/packages/react-native-device-activity/package.json index 3468acd2..0d3fbb54 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.4.31", + "version": "0.6.1", "description": "Provides access to Apples DeviceActivity API", "main": "build/index.js", "types": "build/index.d.ts", 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 00000000..79f6d676 --- /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 00000000..694d8b41 --- /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 00000000..5a4c0947 --- /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 00000000..6d560269 --- /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 229db075..da7edd32 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 */ @@ -219,6 +234,28 @@ type CommonTypeParams = { neverTriggerBefore?: Date; }; +export type WebContentFilterPolicyInput = + | { + type: "none"; + domains?: undefined; + exceptDomains?: undefined; + } + | { + type: "auto"; + domains?: string[]; + exceptDomains?: string[]; + } + | { + type: "specific"; + domains: string[]; + exceptDomains?: undefined; + } + | { + type: "all"; + domains?: undefined; + exceptDomains?: string[]; + }; + export type Action = | ({ type: "blockSelection"; @@ -287,6 +324,31 @@ export type Action = } & CommonTypeParams) | ({ type: "removeAllDeliveredNotifications"; + } & CommonTypeParams) + | ({ + type: "setWebContentFilterPolicy"; + policy: WebContentFilterPolicyInput; + } & CommonTypeParams) + | ({ + type: "clearWebContentFilterPolicy"; + } & CommonTypeParams) + | ({ + type: "startMonitoring"; + activityName: string; + deviceActivityEvents: DeviceActivityEvent[]; + /** + * Optional delay in milliseconds from now for intervalStart. + * If provided, will override deviceActivitySchedule.intervalStart. + */ + intervalStartDelayMs?: number; + intervalEndDelayMs?: number; + } & CommonTypeParams) + | ({ + type: "stopMonitoring"; + /** + * Optional array of activity names to stop. If not provided, stops all monitoring. + */ + activityNames?: string[]; } & CommonTypeParams); export type DeviceActivityEventRaw = Omit< @@ -450,6 +512,12 @@ export type ReactNativeDeviceActivityNativeModule = { ) => void; clearWhitelistAndUpdateBlock: (triggeredBy?: string) => void; clearWhitelist: () => void; + setWebContentFilterPolicy: ( + policy: WebContentFilterPolicyInput, + triggeredBy?: string, + ) => void; + clearWebContentFilterPolicy: (triggeredBy?: string) => void; + isWebContentFilterPolicyActive: () => boolean; // reset, reload things reloadDeviceActivityCenter: () => void; diff --git a/packages/react-native-device-activity/src/ReactNativeDeviceActivityModule.ts b/packages/react-native-device-activity/src/ReactNativeDeviceActivityModule.ts index 95f916cd..33738daf 100644 --- a/packages/react-native-device-activity/src/ReactNativeDeviceActivityModule.ts +++ b/packages/react-native-device-activity/src/ReactNativeDeviceActivityModule.ts @@ -77,6 +77,9 @@ const mockModule: ReactNativeDeviceActivityNativeModule | null = { clearWhitelistAndUpdateBlock: warnFn, convertToIncludeCategories: warnFnActivitySelectionWithMetadata, refreshManagedSettingsStore: warnFn, + clearWebContentFilterPolicy: warnFn, + setWebContentFilterPolicy: warnFn, + isWebContentFilterPolicyActive: warnFnBoolean, removeSelectionFromWhitelistAndUpdateBlock: warnFn, renameActivitySelection: warnFn, resetBlocks: warnFn, diff --git a/packages/react-native-device-activity/src/index.test.ts b/packages/react-native-device-activity/src/index.test.ts index db8d511c..5b237fd7 100644 --- a/packages/react-native-device-activity/src/index.test.ts +++ b/packages/react-native-device-activity/src/index.test.ts @@ -1,24 +1,88 @@ -// 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", () => ({ - stopMonitoring: mockStopMonitoring, - })); - const { stopMonitoring } = require("./"); - stopMonitoring(); + + jest.isolateModules(() => { + jest.doMock("./ReactNativeDeviceActivityModule", () => ({ + stopMonitoring: mockStopMonitoring, + })); + const { stopMonitoring } = require("./"); + stopMonitoring(); + }); + expect(mockStopMonitoring).toHaveBeenCalled(); }); test("Should call startMonitoring", () => { - jest.resetAllMocks(); const mockStartMonitoring = jest.fn(); - jest.mock("./ReactNativeDeviceActivityModule", () => ({ - startMonitoring: mockStartMonitoring, - })); - const { startMonitoring } = require("./"); - startMonitoring("test", {}, []); + + jest.isolateModules(() => { + jest.doMock("./ReactNativeDeviceActivityModule", () => ({ + startMonitoring: mockStartMonitoring, + })); + const { startMonitoring } = require("./"); + startMonitoring("test", {}, []); + }); + expect(mockStartMonitoring).toHaveBeenCalled(); }); + + test("Should call setWebContentFilterPolicy", () => { + const mockSetWebContentFilterPolicy = jest.fn(); + const policy = { + type: "auto", + domains: ["adult.example.com"], + exceptDomains: ["safe.example.com"], + }; + + jest.isolateModules(() => { + jest.doMock("./ReactNativeDeviceActivityModule", () => ({ + setWebContentFilterPolicy: mockSetWebContentFilterPolicy, + })); + const { setWebContentFilterPolicy } = require("./"); + setWebContentFilterPolicy(policy, "test"); + }); + + expect(mockSetWebContentFilterPolicy).toHaveBeenCalledWith(policy, "test"); + }); + + test("Should call clearWebContentFilterPolicy", () => { + const mockClearWebContentFilterPolicy = jest.fn(); + + jest.isolateModules(() => { + jest.doMock("./ReactNativeDeviceActivityModule", () => ({ + clearWebContentFilterPolicy: mockClearWebContentFilterPolicy, + })); + const { clearWebContentFilterPolicy } = require("./"); + clearWebContentFilterPolicy("test"); + }); + + expect(mockClearWebContentFilterPolicy).toHaveBeenCalledWith("test"); + }); + + test("Should return native value for isWebContentFilterPolicyActive", () => { + jest.isolateModules(() => { + jest.doMock("./ReactNativeDeviceActivityModule", () => ({ + isWebContentFilterPolicyActive: () => true, + })); + const { isWebContentFilterPolicyActive } = require("./"); + expect(isWebContentFilterPolicyActive()).toBe(true); + }); + }); + + test("Should return false fallback for isWebContentFilterPolicyActive", () => { + jest.isolateModules(() => { + jest.doMock("./ReactNativeDeviceActivityModule", () => ({})); + const { isWebContentFilterPolicyActive } = require("./"); + expect(isWebContentFilterPolicyActive()).toBe(false); + }); + }); }); diff --git a/packages/react-native-device-activity/src/index.ts b/packages/react-native-device-activity/src/index.ts index 47f4ed4a..0656b8dc 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, @@ -28,6 +32,7 @@ import { ShieldActions, ShieldConfiguration, OnAuthorizationStatusChange, + WebContentFilterPolicyInput, } from "./ReactNativeDeviceActivity.types"; import ReactNativeDeviceActivityModule from "./ReactNativeDeviceActivityModule"; @@ -39,8 +44,8 @@ export async function requestAuthorization( forIndividualOrChild, ); } catch (error) { - // seems like we get a promise rejection if the user denies the authorization, but we can still request again - console.error(error); + // Re-throw the error so it can be properly handled by the caller + throw error; } } @@ -397,6 +402,12 @@ export function isShieldActive(): boolean { return ReactNativeDeviceActivityModule?.isShieldActive() ?? false; } +export function isWebContentFilterPolicyActive(): boolean { + return ( + ReactNativeDeviceActivityModule?.isWebContentFilterPolicyActive() ?? false + ); +} + export function moveFile( sourceUri: string, destinationUri: string, @@ -447,6 +458,26 @@ export function resetBlocks(triggeredBy?: string): void { return ReactNativeDeviceActivityModule?.resetBlocks(triggeredBy); } +export function setWebContentFilterPolicy( + policy: WebContentFilterPolicyInput, + triggeredBy?: string, +): void { + try { + return ReactNativeDeviceActivityModule?.setWebContentFilterPolicy( + policy, + triggeredBy, + ); + } catch (error) { + handleScreenTimeError(error); + } +} + +export function clearWebContentFilterPolicy(triggeredBy?: string): void { + return ReactNativeDeviceActivityModule?.clearWebContentFilterPolicy( + triggeredBy, + ); +} + export function unblockSelection( familyActivitySelection: ActivitySelectionInput, triggeredBy?: string, @@ -649,9 +680,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, }; diff --git a/packages/react-native-device-activity/targets/ShieldAction/ShieldActionExtension.swift b/packages/react-native-device-activity/targets/ShieldAction/ShieldActionExtension.swift index 80193fd5..da5121ea 100644 --- a/packages/react-native-device-activity/targets/ShieldAction/ShieldActionExtension.swift +++ b/packages/react-native-device-activity/targets/ShieldAction/ShieldActionExtension.swift @@ -31,7 +31,7 @@ func handleShieldAction( } if let type = configForSelectedAction["type"] as? String { - logger.log("type: \(type)") + logger.log("type: \(type, privacy: .public)") if type == "disableBlockAllMode" { disableBlockAllMode(triggeredBy: "shieldAction") } diff --git a/packages/react-native-device-activity/targets/ShieldConfiguration/ShieldConfigurationExtension.swift b/packages/react-native-device-activity/targets/ShieldConfiguration/ShieldConfigurationExtension.swift index 48d628e9..63fca8e0 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