Skip to content

Commit 44fe314

Browse files
authored
feat: add web content filter policy API for adult/explicit website blocking (#86)
* feat: add web content filter policy API with native/action support, docs, and tests * chore: add example web filter controls and fix swiftlint
1 parent 50b83ec commit 44fe314

11 files changed

Lines changed: 800 additions & 20 deletions

File tree

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ React Native wrapper for Apple's Screen Time, Device Activity, and Family Contro
2525
- [Select Apps to track](#select-apps-to-track)
2626
- [Time tracking](#time-tracking)
2727
- [Block the shield](#block-the-shield)
28+
- [Web Content Filter Policy](#web-content-filter-policy)
2829
- [Alternative Example: Blocking Apps for a Time Slot](#alternative-example-blocking-apps-for-a-time-slot)
2930
- [Key Concepts Explained](#key-concepts-explained)
3031
- [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(
428429
)
429430
```
430431

432+
### Web Content Filter Policy
433+
434+
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).
435+
436+
```typescript
437+
import * as ReactNativeDeviceActivity from "react-native-device-activity";
438+
439+
// Block adult/explicit websites using Apple's automatic filter.
440+
ReactNativeDeviceActivity.setWebContentFilterPolicy({
441+
type: "auto",
442+
});
443+
```
444+
445+
You can also provide explicit blocked and exception domains:
446+
447+
```typescript
448+
ReactNativeDeviceActivity.setWebContentFilterPolicy({
449+
type: "auto",
450+
domains: ["example-adult-site.com"],
451+
exceptDomains: ["example.com"],
452+
});
453+
```
454+
455+
`specific` and `all` are also supported:
456+
457+
```typescript
458+
// Block only the listed domains
459+
ReactNativeDeviceActivity.setWebContentFilterPolicy({
460+
type: "specific",
461+
domains: ["example.com", "another-example.com"],
462+
});
463+
464+
// Block all websites except the listed domains
465+
ReactNativeDeviceActivity.setWebContentFilterPolicy({
466+
type: "all",
467+
exceptDomains: ["example.com"],
468+
});
469+
```
470+
471+
To clear web-content filtering:
472+
473+
```typescript
474+
ReactNativeDeviceActivity.clearWebContentFilterPolicy();
475+
```
476+
477+
To check whether a filter policy is currently active:
478+
479+
```typescript
480+
const isActive = ReactNativeDeviceActivity.isWebContentFilterPolicyActive();
481+
```
482+
483+
You can configure this from background actions as well:
484+
485+
```typescript
486+
ReactNativeDeviceActivity.configureActions({
487+
activityName: "school-hours",
488+
callbackName: "intervalDidStart",
489+
actions: [
490+
{
491+
type: "setWebContentFilterPolicy",
492+
policy: {
493+
type: "auto",
494+
},
495+
},
496+
],
497+
});
498+
```
499+
500+
Notes:
501+
- Apple currently limits `domains` and `exceptDomains` to 50 entries each (depending on selected filter policy).
502+
- Invalid or malformed policy input throws (for example: unknown `type`, missing required arrays, empty domains, or lists over the limit).
503+
431504
## Alternative Example: Blocking Apps for a Time Slot
432505

433506
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
623696
| `setFamilyActivitySelectionId` | `{ id: string, familyActivitySelection: string }` | void | Store a family activity selection with given ID |
624697
| `updateShield` | `config`: ShieldConfiguration<br>`actions`: ShieldActions | void | Update the shield UI and actions |
625698
| `configureActions` | `{ activityName: string, callbackName: string, actions: Action[] }` | void | Configure actions for monitor events |
699+
| `setWebContentFilterPolicy` | `policy`: WebContentFilterPolicyInput<br>`triggeredBy?`: string | void | Apply web filtering policy (`auto`, `specific`, `all`, `none`) |
700+
| `clearWebContentFilterPolicy` | `triggeredBy?`: string | void | Clear only the web content filter policy |
701+
| `isWebContentFilterPolicyActive` | None | boolean | Return whether web content filter policy is active |
626702
| `getEvents` | None | DeviceActivityEvent[] | Get history of triggered events |
627703
| `userDefaultsSet` | `key`: string<br>`value`: any | void | Store value in shared UserDefaults |
628704
| `userDefaultsGet` | `key`: string | any | Retrieve value from shared UserDefaults |

apps/example/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,7 +1601,7 @@ PODS:
16011601
- React-logger
16021602
- React-perflogger
16031603
- React-utils (= 0.76.9)
1604-
- ReactNativeDeviceActivity (0.4.30):
1604+
- ReactNativeDeviceActivity (0.5.3):
16051605
- ExpoModulesCore
16061606
- RNVectorIcons (10.2.0):
16071607
- DoubleConversion
@@ -1947,7 +1947,7 @@ SPEC CHECKSUMS:
19471947
React-utils: ed818f19ab445000d6b5c4efa9d462449326cc9f
19481948
ReactCodegen: 2a46abb2e345dc8efaff0a724f5f8639230eb974
19491949
ReactCommon: 300d8d9c5cb1a6cd79a67cf5d8f91e4d477195f9
1950-
ReactNativeDeviceActivity: 1017837e25b5b5247b85d7a542b5176b27ee85d3
1950+
ReactNativeDeviceActivity: 49c0579e2cbb940f4366b917d9e17491a70c5bd7
19511951
RNVectorIcons: 4330d8f8f8f4184f436e0c08ae9950431ffe466e
19521952
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
19531953
SwiftLint: 365bcd9ffc83d0deb874e833556d82549919d6cd
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import ManagedSettings
2+
import XCTest
3+
4+
@available(iOS 15.0, *)
5+
class WebContentFilterPolicyTests: XCTestCase {
6+
override func setUp() {
7+
super.setUp()
8+
clearWebContentFilterPolicy(triggeredBy: "WebContentFilterPolicyTests.setUp")
9+
}
10+
11+
override func tearDown() {
12+
clearWebContentFilterPolicy(triggeredBy: "WebContentFilterPolicyTests.tearDown")
13+
super.tearDown()
14+
}
15+
16+
func testAutoPolicyParsesDomainsAndExceptions() throws {
17+
let parsed = try parseWebContentFilterPolicyInput(
18+
policyInput: [
19+
"type": "auto",
20+
"domains": [
21+
"https://adult.example.com/path"
22+
],
23+
"exceptDomains": [
24+
"safe.example.com"
25+
]
26+
]
27+
)
28+
29+
switch parsed.policy {
30+
case .auto(let domains, except: let exceptDomains):
31+
XCTAssertEqual(domains.compactMap(\.domain).sorted(), ["adult.example.com"])
32+
XCTAssertEqual(exceptDomains.compactMap(\.domain).sorted(), ["safe.example.com"])
33+
default:
34+
XCTFail("Expected auto policy")
35+
}
36+
}
37+
38+
func testSpecificPolicyRejectsMoreThanFiftyDomains() {
39+
// 51 unique domains should fail (Apple limit is 50)
40+
let domains = (1...51).map { index in
41+
return "blocked-\(index).example.com"
42+
}
43+
44+
XCTAssertThrowsError(
45+
try parseWebContentFilterPolicyInput(
46+
policyInput: [
47+
"type": "specific",
48+
"domains": domains
49+
]
50+
)
51+
)
52+
}
53+
54+
func testSpecificPolicyAllowsExactlyFiftyDomains() throws {
55+
let domains = (1...50).map { index in
56+
return "blocked-\(index).example.com"
57+
}
58+
59+
let parsed = try parseWebContentFilterPolicyInput(
60+
policyInput: [
61+
"type": "specific",
62+
"domains": domains
63+
]
64+
)
65+
66+
switch parsed.policy {
67+
case .specific(let parsedDomains):
68+
XCTAssertEqual(parsedDomains.count, 50)
69+
default:
70+
XCTFail("Expected specific policy")
71+
}
72+
}
73+
74+
func testAllPolicyRejectsMoreThanFiftyExceptions() {
75+
// 51 unique domains should fail (Apple limit is 50)
76+
let domains = (1...51).map { index in
77+
return "allowed-\(index).example.com"
78+
}
79+
80+
XCTAssertThrowsError(
81+
try parseWebContentFilterPolicyInput(
82+
policyInput: [
83+
"type": "all",
84+
"exceptDomains": domains
85+
]
86+
)
87+
)
88+
}
89+
90+
func testAutoPolicyNormalizesQueryAndFragmentDomains() throws {
91+
let parsed = try parseWebContentFilterPolicyInput(
92+
policyInput: [
93+
"type": "auto",
94+
"domains": ["example.com?foo=1"],
95+
"exceptDomains": ["safe.example.com#top"]
96+
]
97+
)
98+
99+
switch parsed.policy {
100+
case .auto(let domains, except: let exceptDomains):
101+
XCTAssertEqual(domains.compactMap(\.domain).sorted(), ["example.com"])
102+
XCTAssertEqual(exceptDomains.compactMap(\.domain).sorted(), ["safe.example.com"])
103+
default:
104+
XCTFail("Expected auto policy")
105+
}
106+
}
107+
108+
func testClearPolicyDeactivatesFilter() throws {
109+
try setWebContentFilterPolicy(
110+
policyInput: [
111+
"type": "auto"
112+
],
113+
triggeredBy: "WebContentFilterPolicyTests.testClearPolicyDeactivatesFilter"
114+
)
115+
116+
XCTAssertTrue(isWebContentFilterPolicyActive())
117+
118+
clearWebContentFilterPolicy(
119+
triggeredBy: "WebContentFilterPolicyTests.testClearPolicyDeactivatesFilter"
120+
)
121+
122+
XCTAssertFalse(isWebContentFilterPolicyActive())
123+
}
124+
125+
func testExecuteGenericActionAppliesAndClearsPolicy() {
126+
executeGenericAction(
127+
action: [
128+
"type": "setWebContentFilterPolicy",
129+
"policy": [
130+
"type": "auto",
131+
"domains": ["adult.example.com"],
132+
"exceptDomains": ["safe.example.com"]
133+
]
134+
],
135+
placeholders: [:],
136+
triggeredBy: "WebContentFilterPolicyTests.testExecuteGenericActionAppliesAndClearsPolicy"
137+
)
138+
139+
XCTAssertTrue(isWebContentFilterPolicyActive())
140+
141+
executeGenericAction(
142+
action: [
143+
"type": "clearWebContentFilterPolicy"
144+
],
145+
placeholders: [:],
146+
triggeredBy: "WebContentFilterPolicyTests.testExecuteGenericActionAppliesAndClearsPolicy"
147+
)
148+
149+
XCTAssertFalse(isWebContentFilterPolicyActive())
150+
}
151+
}

apps/example/screens/AllTheThings.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export function AllTheThings() {
179179
const [isShieldActive, setIsShieldActive] = useState(false);
180180
const [isShieldActiveWithSelection, setIsShieldActiveWithSelection] =
181181
useState(false);
182+
const [isWebContentFilterPolicyActive, setIsWebContentFilterPolicyActive] =
183+
useState(false);
182184

183185
const refreshIsShieldActive = useCallback(() => {
184186
setIsShieldActive(ReactNativeDeviceActivity.isShieldActive());
@@ -200,6 +202,17 @@ export function AllTheThings() {
200202
}
201203
}, []);
202204

205+
const refreshWebContentFilterPolicyActive = useCallback(() => {
206+
setIsWebContentFilterPolicyActive(
207+
ReactNativeDeviceActivity.isWebContentFilterPolicyActive(),
208+
);
209+
}, []);
210+
211+
useEffect(() => {
212+
refreshIsShieldActive();
213+
refreshWebContentFilterPolicyActive();
214+
}, [refreshIsShieldActive, refreshWebContentFilterPolicyActive]);
215+
203216
const [pickerVisible, setPickerVisible] = useState(false);
204217

205218
return (
@@ -221,6 +234,10 @@ export function AllTheThings() {
221234
Shielding current selection:
222235
{isShieldActiveWithSelection ? "✅" : "❌"}
223236
</Text>
237+
<Text>
238+
Web content filter active:
239+
{isWebContentFilterPolicyActive ? "✅" : "❌"}
240+
</Text>
224241

225242
<Button
226243
title={
@@ -249,6 +266,67 @@ export function AllTheThings() {
249266
/>
250267

251268
<Button title="Get events" onPress={refreshEvents} />
269+
<Button
270+
title="Refresh web filter status"
271+
onPress={refreshWebContentFilterPolicyActive}
272+
/>
273+
<Button
274+
title="Web filter: auto (adult + explicit)"
275+
onPress={() => {
276+
try {
277+
ReactNativeDeviceActivity.setWebContentFilterPolicy({
278+
type: "auto",
279+
});
280+
refreshWebContentFilterPolicyActive();
281+
} catch (error) {
282+
Alert.alert(
283+
"Failed to set web filter",
284+
error instanceof Error ? error.message : "Unknown error",
285+
);
286+
}
287+
}}
288+
/>
289+
<Button
290+
title="Web filter: specific domains only"
291+
onPress={() => {
292+
try {
293+
ReactNativeDeviceActivity.setWebContentFilterPolicy({
294+
type: "specific",
295+
domains: ["example.com", "example.org"],
296+
});
297+
refreshWebContentFilterPolicyActive();
298+
} catch (error) {
299+
Alert.alert(
300+
"Failed to set web filter",
301+
error instanceof Error ? error.message : "Unknown error",
302+
);
303+
}
304+
}}
305+
/>
306+
<Button
307+
title="Web filter: all except example.com"
308+
onPress={() => {
309+
try {
310+
ReactNativeDeviceActivity.setWebContentFilterPolicy({
311+
type: "all",
312+
exceptDomains: ["example.com"],
313+
});
314+
refreshWebContentFilterPolicyActive();
315+
} catch (error) {
316+
Alert.alert(
317+
"Failed to set web filter",
318+
error instanceof Error ? error.message : "Unknown error",
319+
);
320+
}
321+
}}
322+
/>
323+
<Button
324+
title="Clear web filter policy"
325+
onPress={() => {
326+
ReactNativeDeviceActivity.clearWebContentFilterPolicy();
327+
refreshWebContentFilterPolicyActive();
328+
}}
329+
/>
252330

253331
<Button
254332
title="Block all apps"

0 commit comments

Comments
 (0)