diff --git a/README.md b/README.md index aa46986e..da56bde5 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,35 @@ Provides direct access to Apples Screen Time, Device Activity and Shielding APIs 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. -# Examples +# Examples & Use Cases + +## Handle permissions + +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. ```TypeScript +import React, { useEffect } from 'react'; import * as ReactNativeDeviceActivity from "react-native-device-activity"; +import React, { useEffect } from 'react'; + +useEffect(() => { + ReactNativeDeviceActivity.requestAuthorization(); +}, []) + +You can also revoke permissions: + +```TypeScript +import * as ReactNativeDeviceActivity from "react-native-device-activity"; + +ReactNativeDeviceActivity.revokeAuthorization(); + +## Select Apps to track + +For most use cases you need to get an activitySelection from the user, which is a token representing the apps the user wants to track, block or whitelist. This can be done by presenting the native view: + +```TypeScript +import * as ReactNativeDeviceActivity from "react-native-device-activity"; + const DeviceActivityPicker = () => { // First things first, you need to request authorization @@ -36,11 +61,24 @@ 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. + +## 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. + +```TypeScript +import * as ReactNativeDeviceActivity from "react-native-device-activity"; // once you have authorization and got hold of the familyActivitySelection (which is a base64 string) you can start tracking with it: const trackDeviceActivity = (activitySelection: string) => { ReactNativeDeviceActivity.startMonitoring( - "DeviceActivity.AppLoggedTimeDaily", + "TimeTrackingActivity", { // repeat logging every 24 hours intervalStart: { hour: 0, minute: 0, second: 0 }, @@ -49,7 +87,7 @@ const trackDeviceActivity = (activitySelection: string) => { }, events: [ { - eventName: 'user_activity_reached_10_minutes', + eventName: 'minutes_reached_10', // remember to give event names that make it possible for you to extract time at a later stage, if you want to access this information familyActivitySelection: activitySelection, threshold: { minute: 10 }, } @@ -57,7 +95,7 @@ const trackDeviceActivity = (activitySelection: string) => { ); } -// you can listen to events (which I guess only works when the app is alive): +// you can listen to events (which only works when the app is alive): const listener = ReactNativeDeviceActivity.onDeviceActivityMonitorEvent( (event) => { const name = event.nativeEvent.callbackName; // the name of the event @@ -76,6 +114,102 @@ const listener = ReactNativeDeviceActivity.onDeviceActivityMonitorEvent( 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. + +## Block the shield + +To block apps, you can do it directly from your code. + +```TypeScript +import * as ReactNativeDeviceActivity from "react-native-device-activity"; + +// block all apps +ReactNativeDeviceActivity.blockSelection({ + activitySelectionId: selectionId, +}); +``` + +But for many use cases you want to do this in the Swift process, which is why you can specify actions when setting up events: + +```TypeScript +const trackDeviceActivity = (activitySelection: string) => { + ReactNativeDeviceActivity.startMonitoring( + "BlockAfter10Minutes", + { + // repeat logging every 24 hours + intervalStart: { hour: 0, minute: 0, second: 0 }, + intervalEnd: { hour: 23, minute: 59, second: 59 }, + repeats: true, + }, + events: [ + { + eventName: 'minutes_reached_10', // remember to give event names that make it possible for you to extract time at a later stage, if you want to access this information + familyActivitySelection: activitySelection, + threshold: { minute: 10 }, + actions: [ + { + type: "blockSelection", + familyActivitySelectionId, + } + ] + } + ] + ); +} +``` + +There are many other actions you can perform, like sending web requests or notifications. The easiest way to explore this is by using TypeScript, which is easier to keep up-to-date than this documentation. + +You can also configure the shield UI and actions of the shield (this can also be done in the Swift process with actions): + +```TypeScript +ReactNativeDeviceActivity.updateShield( + { + title: shieldTitle, + backgroundBlurStyle: UIBlurEffectStyle.systemMaterialDark, + // backgroundColor: null, + titleColor: { + red: 255, + green: 0, + blue: 0, + }, + subtitle: "subtitle", + subtitleColor: { + red: Math.random() * 255, + green: Math.random() * 255, + blue: Math.random() * 255, + }, + primaryButtonBackgroundColor: { + red: Math.random() * 255, + green: Math.random() * 255, + blue: Math.random() * 255, + }, + primaryButtonLabelColor: { + red: Math.random() * 255, + green: Math.random() * 255, + blue: Math.random() * 255, + }, + secondaryButtonLabelColor: { + red: Math.random() * 255, + green: Math.random() * 255, + blue: Math.random() * 255, + }, + }, + { + primary: { + type: "disableBlockAllMode", + behavior: "defer", + }, + secondary: { + type: "dismiss", + behavior: "close", + }, + }, +) +``` + # Installation in managed Expo projects For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release. @@ -111,15 +245,17 @@ For Expo to be able to automatically handle provisioning you need to specify ext You can potentially modify the targets manually, although you risk the library and your app code diverging. If you want to disable the automatic copying of the targets, you can set `copyToTargetFolder` to `false` in the plugin configuration [as seen here](https://github.com/Intentional-Digital/react-native-device-activity/blob/main/example/app.json#L53). ## Some notes -- 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. + +- 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. ## 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. +- 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 @@ -160,7 +296,8 @@ Contributions are very welcome! Please refer to guidelines described in the [con # Weird behaviors ⚠️ - Authorization changes outside app not captured -When we've asked whether the user has authorized us to use screen time, and the state is changed outside the app, the native API doesn't update until the app restarts, i.e. this flow: + When we've asked whether the user has authorized us to use screen time, and the state is changed outside the app, the native API doesn't update until the app restarts, i.e. this flow: + 1. Ask for current permission 2. Change permission outside the app 3. Ask for current permission again will return same as (1) @@ -173,6 +310,7 @@ When we've asked whether the user has authorized us to use screen time, and the - The DeviceActivitySelectionView is prone to crashes, which is outside of our control. The best we can do is provide fallback views that allows the user to know what's happening and reload the view. # Troubleshooting 📱 + The Screen Time APIs are known to be very finnicky. Here are some things you can try to troubleshoot events not being reported: - Disable Low Power Mode (mentioned by Apple Community Specialist [here](https://discussions.apple.com/thread/254808070)) 🪫 @@ -182,4 +320,4 @@ The Screen Time APIs are known to be very finnicky. Here are some things you can - Make sure device is not low on storage (mentioned by Apple Community Specialist [here](https://discussions.apple.com/thread/254808070)) 💾 - Upgrade iOS version - Content & Privacy Restrictions: If any restrictions are enabled under Screen Time’s Content & Privacy Restrictions, ensure none are blocking your app. -- Reset all device settings \ No newline at end of file +- Reset all device settings diff --git a/apps/example/ios/Tests/SkipTests.swift b/apps/example/ios/Tests/SkipTests.swift index abb3d0ad..9f311252 100644 --- a/apps/example/ios/Tests/SkipTests.swift +++ b/apps/example/ios/Tests/SkipTests.swift @@ -1,3 +1,4 @@ +import FamilyControls import XCTest class NeverTriggerBeforeTests: XCTestCase { @@ -19,6 +20,9 @@ class NeverTriggerBeforeTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -34,6 +38,9 @@ class NeverTriggerBeforeTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -69,6 +76,9 @@ class SkipIfTriggeredBeforeTests: XCTestCase { skipIfAlreadyTriggeredBefore: 1001, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -84,6 +94,9 @@ class SkipIfTriggeredBeforeTests: XCTestCase { skipIfAlreadyTriggeredBefore: 1000, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -118,6 +131,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: 500, skipIfAlreadyTriggeredBetweenToDate: 1500, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -133,6 +149,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: 1001, skipIfAlreadyTriggeredBetweenToDate: 1500, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -148,6 +167,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: 500, skipIfAlreadyTriggeredBetweenToDate: 999, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -180,6 +202,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -195,6 +220,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -228,6 +256,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -243,6 +274,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -277,6 +311,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -292,6 +329,9 @@ class SkipIfAlreadyTriggeredBetweenTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -332,6 +372,9 @@ class SkipIfLargerTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -347,6 +390,9 @@ class SkipIfLargerTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: eventName @@ -390,6 +436,9 @@ class SkipIfLargerTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: "15" @@ -405,6 +454,9 @@ class SkipIfLargerTests: XCTestCase { skipIfAlreadyTriggeredBefore: nil, skipIfAlreadyTriggeredBetweenFromDate: nil, skipIfAlreadyTriggeredBetweenToDate: nil, + skipIfWhitelistOrBlacklistIsUnchanged: false, + originalWhitelist: FamilyActivitySelection(), + originalBlocklist: FamilyActivitySelection(), activityName: activityName, callbackName: callbackName, eventName: "5" diff --git a/packages/react-native-device-activity/ios/Shared.swift b/packages/react-native-device-activity/ios/Shared.swift index 15782282..91a50fc7 100644 --- a/packages/react-native-device-activity/ios/Shared.swift +++ b/packages/react-native-device-activity/ios/Shared.swift @@ -1233,6 +1233,15 @@ func hasHigherTriggeredEvent( return false } +func isEqual( + _ selection1: FamilyActivitySelection, + _ selection2: FamilyActivitySelection +) -> Bool { + let diff = symmetricDifference(selection1, selection2) + return diff.categoryTokens.isEmpty && diff.applicationTokens.isEmpty + && diff.webDomainTokens.isEmpty +} + func shouldExecuteAction( skipIfAlreadyTriggeredAfter: Double?, skipIfLargerEventRecordedAfter: Double?, @@ -1243,6 +1252,9 @@ func shouldExecuteAction( skipIfAlreadyTriggeredBefore: Double?, skipIfAlreadyTriggeredBetweenFromDate: Double?, skipIfAlreadyTriggeredBetweenToDate: Double?, + skipIfWhitelistOrBlacklistIsUnchanged: Bool?, + originalWhitelist: FamilyActivitySelection, + originalBlocklist: FamilyActivitySelection, activityName: String, callbackName: String, eventName: String? @@ -1254,6 +1266,16 @@ func shouldExecuteAction( } } + if let skipIfWhitelistOrBlacklistIsUnchanged = skipIfWhitelistOrBlacklistIsUnchanged { + if skipIfWhitelistOrBlacklistIsUnchanged { + let whitelistIsEqual = isEqual(originalWhitelist, getCurrentWhitelist()) + let blocklistIsEqual = isEqual(originalBlocklist, getCurrentBlocklist()) + if whitelistIsEqual && blocklistIsEqual { + return false + } + } + } + if let skipIfAlreadyTriggeredAfter = skipIfAlreadyTriggeredAfter { if let lastTriggeredAt = getLastTriggeredTimeFromUserDefaults( activityName: activityName, diff --git a/packages/react-native-device-activity/package.json b/packages/react-native-device-activity/package.json index 73fc3ffa..7705894a 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.28", + "version": "0.4.30", "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/ReactNativeDeviceActivity.types.ts b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts index c02b8040..229db075 100644 --- a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts +++ b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts @@ -214,6 +214,7 @@ type CommonTypeParams = { skipIfAlreadyTriggeredWithinMS?: number; skipIfLargerEventRecordedWithinMS?: number; skipIfAlreadyTriggeredBefore?: Date; + skipIfWhitelistOrBlacklistIsUnchanged?: boolean; skipIfLargerEventRecordedSinceIntervalStarted?: boolean; neverTriggerBefore?: Date; }; diff --git a/packages/react-native-device-activity/targets/ActivityMonitorExtension/DeviceActivityMonitorExtension.swift b/packages/react-native-device-activity/targets/ActivityMonitorExtension/DeviceActivityMonitorExtension.swift index 7e0fb4b0..f3d73de8 100644 --- a/packages/react-native-device-activity/targets/ActivityMonitorExtension/DeviceActivityMonitorExtension.swift +++ b/packages/react-native-device-activity/targets/ActivityMonitorExtension/DeviceActivityMonitorExtension.swift @@ -65,6 +65,9 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor { "eventName": eventName ] + let originalWhitelist = getCurrentWhitelist() + let originalBlocklist = getCurrentBlocklist() + CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication) if let actions = userDefaults?.array(forKey: triggeredBy) { @@ -85,6 +88,9 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor { let skipIfAlreadyTriggeredBetweenToDate = action["skipIfAlreadyTriggeredBetweenToDate"] as? Double + let skipIfWhitelistOrBlacklistIsUnchanged = + action["skipIfWhitelistOrBlacklistIsUnchanged"] as? Bool + if shouldExecuteAction( skipIfAlreadyTriggeredAfter: skipIfAlreadyTriggeredAfter, skipIfLargerEventRecordedAfter: skipIfLargerEventRecordedAfter, @@ -96,6 +102,9 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor { skipIfAlreadyTriggeredBefore: skipIfAlreadyTriggeredBefore, skipIfAlreadyTriggeredBetweenFromDate: skipIfAlreadyTriggeredBetweenFromDate, skipIfAlreadyTriggeredBetweenToDate: skipIfAlreadyTriggeredBetweenToDate, + skipIfWhitelistOrBlacklistIsUnchanged: skipIfWhitelistOrBlacklistIsUnchanged, + originalWhitelist: originalWhitelist, + originalBlocklist: originalBlocklist, activityName: activityName, callbackName: callbackName, eventName: eventName