From be45cc5321e2cb758559640c19c453487a266bec Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Tue, 10 Feb 2026 12:25:19 +0100 Subject: [PATCH 1/2] feat: add web content filter policy API with native/action support, docs, and tests --- README.md | 76 ++++ apps/example/ios/Podfile.lock | 4 +- .../Tests/WebContentFilterPolicyTests.swift | 151 ++++++++ bun.lock | 9 +- .../ios/ReactNativeDeviceActivityModule.swift | 20 ++ .../ios/Shared.swift | 336 ++++++++++++++++++ .../src/ReactNativeDeviceActivity.types.ts | 35 ++ .../src/ReactNativeDeviceActivityModule.ts | 3 + .../src/index.test.ts | 82 ++++- .../react-native-device-activity/src/index.ts | 27 ++ 10 files changed, 723 insertions(+), 20 deletions(-) create mode 100644 apps/example/ios/Tests/WebContentFilterPolicyTests.swift diff --git a/README.md b/README.md index b67d4c35..aaabe7c2 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 40c1cafc..d36bfc58 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 00000000..c039adbd --- /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/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/ios/ReactNativeDeviceActivityModule.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift index c1ccf0fc..e223bf9b 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift @@ -711,6 +711,10 @@ public class ReactNativeDeviceActivityModule: Module { return isShieldActive() } + Function("isWebContentFilterPolicyActive") { + return isWebContentFilterPolicyActive() + } + Function("blockSelection") { (familyActivitySelection: [String: Any], triggeredBy: String?) in let triggeredBy = triggeredBy ?? "blockSelection called manually" @@ -800,6 +804,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 diff --git a/packages/react-native-device-activity/ios/Shared.swift b/packages/react-native-device-activity/ios/Shared.swift index 44f48b6d..0eb3d361 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 @@ -190,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" { @@ -521,6 +551,312 @@ 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( diff --git a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts index 5ae9bcf4..da7edd32 100644 --- a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts +++ b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts @@ -234,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"; @@ -303,6 +325,13 @@ export type Action = | ({ type: "removeAllDeliveredNotifications"; } & CommonTypeParams) + | ({ + type: "setWebContentFilterPolicy"; + policy: WebContentFilterPolicyInput; + } & CommonTypeParams) + | ({ + type: "clearWebContentFilterPolicy"; + } & CommonTypeParams) | ({ type: "startMonitoring"; activityName: string; @@ -483,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 b32e1b29..5b237fd7 100644 --- a/packages/react-native-device-activity/src/index.test.ts +++ b/packages/react-native-device-activity/src/index.test.ts @@ -1,5 +1,3 @@ -// todo: skipping for now - describe("test", () => { test("Should export sheet picker views", () => { jest.isolateModules(() => { @@ -11,22 +9,80 @@ describe("test", () => { 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 0e01eaaa..0656b8dc 100644 --- a/packages/react-native-device-activity/src/index.ts +++ b/packages/react-native-device-activity/src/index.ts @@ -32,6 +32,7 @@ import { ShieldActions, ShieldConfiguration, OnAuthorizationStatusChange, + WebContentFilterPolicyInput, } from "./ReactNativeDeviceActivity.types"; import ReactNativeDeviceActivityModule from "./ReactNativeDeviceActivityModule"; @@ -401,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, @@ -451,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, From d0f0186525c7dd70d78c295fe76f27c8298a86d3 Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Tue, 10 Feb 2026 12:29:17 +0100 Subject: [PATCH 2/2] chore: add example web filter controls and fix swiftlint --- apps/example/screens/AllTheThings.tsx | 78 +++++++++++++++++++ .../ios/Shared.swift | 3 +- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/example/screens/AllTheThings.tsx b/apps/example/screens/AllTheThings.tsx index ab6fd20d..d031d994 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 ? "✅" : "❌"} +