diff --git a/README.md b/README.md
index b67d4c3..aaabe7c 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 ? "✅" : "❌"}
+
+
+