Monitoring App Usage using the Screen Time Framework

In the Streaks 9.1 update, we've added two new tasks: "Decrease Screen Time" and "Increase Screen Time", which work as follows:

  1. In the "Add Task" screen, you select either of these tasks
  2. You can then choose which app(s) they want to monitor
  3. Choose the maximum duration before Streaks determines the task is missed/complete
  4. Streaks will automatically be notified in the background by Screen Time when you reach the maximum duration.

The Screen Time framework was originally introduced as a way to allow parents to remotely control the usage of a child's device. In iOS 16 (2022), new functioanlity was added so the framework could also monitor the device it's running on (not just children's devices).

In order to add this functionality to Streaks, we did the following:

  • Add the "Family Controls" entitlement to the app
  • Add a new screen to the user  choose apps and categories to monitor
  • Create a "device activity" extension to receive background updates about device usage
  • Start the activity monitor

This article shows how each of these steps were achieved using the iOS Screen Time framework.

Add Family Controls to the App

Firstly, you need to add the com.apple.developer.family-controls entitlement to your app. This can be done in the Signing & Capabilities section of the main app target.

Note: Family Controls is a privileged entitlement, meaning you must request permission from Apple in order to publish apps on TestFlight or the App Store that use it.

Once you've done this, your app can then request permission, as follows:

import FamilyControls

let ac = AuthorizationCenter.shared

Task {
    do {
        try await ac.requestAuthorization(for: .individual)
    }
    catch {
        // Some error occurred
    }
}

The call to requestAuthorization uses the .individual parameter, which means it is being used the manage the current device (not a child's device).

The authorization flow for the Screen Time API

Selecting Which Apps to Monitor

To select one or more apps (or app categories) to monitor, you use the .familyActivityPicker modifier in SwiftUI. This picker is used to build a FamilyActivitySelection object, which contains the list of selected apps and categories.

The system-provided privacy-preserving app picker.

First create a data model to store these settings:

class ScreenTimeSelectAppsModel: ObservableObject {
    @Published var activitySelection = FamilyActivitySelection()

    init() { }
}

Next, create the view to present the picker:

import SwiftUI

struct ScreenTimeSelectAppsContentView: View {

    @State private var pickerIsPresented = false
    @ObservedObject var model: ScreenTimeSelectAppsModel

    var body: some View {
        Button {
            pickerIsPresented = true
        } label: {
            Text("Select Apps")
        }
        .familyActivityPicker(
            isPresented: $pickerIsPresented, 
            selection: $model.activitySelection
        )
    }
}

When the button is tapped, the picker is shown and the user can choose apps. Once they dismiss the picker, the FamilyActivitySelection value is updated.

Since Streaks primarily uses UIKit, we needed to bridge to this SwiftUI view using UIHostingController:

import UIKit
import SwiftUI

class SomeViewController: UIViewController {

    // ...
    
    let model = ScreenTimeSelectAppsModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let rootView = ScreenTimeSelectAppsContentView(model: model)
        
        let controller = UIHostingController(rootView: rootView)
        addChild(controller)
        view.addSubview(controller.view)
        controller.view.frame = view.frame
        controller.didMove(toParent: self)
    }
    
    // ...
}

This will embed the SwiftUI view in a UIKit view, which allows you to then open the app chooser.

Next, the selection must be saved. Also, a previously saved selected should populate the initial picker. To do that, we use the model.activitySelection variable.

import UIKit
import SwiftUI

class SomeViewController: UIViewController {

    // ...
    
    private var cancellables = Set<AnyCancellable>()
    
    // Used to encode codable to UserDefaults
    private let encoder = PropertyListEncoder()

    // Used to decode codable from UserDefaults
    private let decoder = PropertyListDecoder()

    private let userDefaultsKey = "ScreenTimeSelection"

    override func viewDidLoad() {
    
        // ...
        
        // Set the initial selection
        model.activitySelection = savedSelection ?? FamilyActivitySelection()
        
        model.$activitySelection.sink { selection in
            self.saveSelection(selection: selection)
        }
        .store(in: &cancellables)
    }
    
    func saveSelection(selection: FamilyActivitySelection) {
    
        let defaults = UserDefaults.standard

        defaults.set(
            try? encoder.encode(selection), 
            forKey: userDefaultsKey
        )
    }
    
    func savedSelection() -> FamilyActivitySelection? {
        let defaults = UserDefaults.standard

        guard let data = defaults.data(forKey: userDefaultsKey) else {
            return nil
        }

        return try? defaults.decode(
            FamilyActivitySelection.self,
            data
        )
    }
    
    // ...
}

This code saves encodes the FamilyActivitySelection and saves it to UserDefaults.

Activate Usage Monitoring

To actually tell iOS to enable the app usage monitoring, you need to use DeviceActivityCenter.

import DeviceActivity

Firstly, you need to define the timeframe over which you're monitoring. In Streaks, a user's task typically resets daily at midnight. This means we want a daily a shedule beginning at midnight:

let schedule = DeviceActivitySchedule(
    intervalStart: DateComponents(hour: 0, minute: 0, second: 0),
    intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
    repeats: true
)

Once you have a schedule, you then need to define an DeviceActivityEvent in order to specify which apps are to be monitored. This uses the app list saved earlier using .familyActivityPicker.

Additionally, here you also specify the threshold parameter, which is the maximum amount of time the apps can be used.

Note: In the case of Streaks, if the user passes that threshold, we mark the task as missed. The Screen Time framework also allows you to "shield" the app from the device user. That is, prevent them from using those apps.
let selection: FamilyActivitySelection = savedSelection()

let timeLimitMinutes = 30

let event = DeviceActivityEvent(
    applications: selection.applicationTokens,
    categories: selection.categoryTokens,
    webDomains: selection.webDomainTokens,
    threshold: DateComponents(minute: timeLimitMinutes)
)

Finally, you can start monitoring event using the defined schedule. You also need to determine names for the activity and the event.

We use the same DeviceActivityName regardless of the task, but we use the event name to uniquely identify which task in the app it belongs to.

In Streaks, you can have multiple screen time tasks, so we use eventName to differentiate between each task.
import DeviceActivity

let center = DeviceActivityCenter()

// Stops existing monitoring. You may or may not need this
// depend on exactly what you're doing.

center.stopMonitoring()

let activity = DeviceActivityName("MyApp.ScreenTime")
let eventName = DeviceActivityEvent.Name("MyApp.SomeEventName")

try center.startMonitoring(
    activity,
    during: schedule,
    events: [
        eventName: event
    ]
)

Device Activity Extension

Once the monitoring has begun, in order to receive updates from the system about the device usage, you need a "Device Activity" extension.

Adding this extension to your project creates a new class in your project that extends from DeviceActivityMonitor. There are a number of methods that can be overridden here, but in the case of Streaks, we're interested in eventDidReachThreshold.

When the user reaches the amount of time specified in the timeLimitMinutes variable above, this method is called in your extension.

import DeviceActivity

class MyMonitorExtension: DeviceActivityMonitor {
    override func eventDidReachThreshold(
        _ event: DeviceActivityEvent.Name, 
        activity: DeviceActivityName
    ) {
        super.eventDidReachThreshold(event, activity: activity)

        // Do something once the user has used their device too much.
    }
    
    // ...
}

The specifics of what you do here really come down to what you're trying to achieve with your app. In the case of Streaks, this is where we then mark that task as missed for the day. To determine which day, we can retrieve the schedule for activity:

let schedule = DeviceActivityCenter().schedule(for: activity)
let nextInterval = schedule?.nextInterval

One other thing we do in Streaks is to provide a warning notification to the user so they know if they're approaching their time limit threshold. To do this, we pass the warningTime parameter to the schedule.

This code defines a one minute warning:

let schedule = DeviceActivitySchedule(
    intervalStart: DateComponents(hour: 0, minute: 0, second: 0),
    intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
    repeats: true,
    warningTime: DateComponents(minute: 1)
)

Now, because warningTime has been specified, the eventWillReachThresholdWarning method will be called, allowing you to warn the user:

import DeviceActivity

class MyMonitorExtension: DeviceActivityMonitor {
    // ...

    override func eventWillReachThresholdWarning(
        _ event: DeviceActivityEvent.Name, 
        activity: DeviceActivityName
    ) {
        super.eventWillReachThresholdWarning(event, activity: activity)

        // Notify the user that they're approaching the time limit
        
        let schedule = DeviceActivityCenter().schedule(for: activity)
        let warningTime = schedule?.warningTime

        // ...
    }
}

One important thing to note about this type of extension is that it has a hard memory limit of 5 MB. This means if you do too much work, the extension will be terminated, resulting in a Jetsam crash report.

Summary

This article shows how we added support for Screen Time in Streaks. While we're using Screen Time in a different. There are many other functions you could add with this framework, but hopefully this has given some insight of how to achieve a basic usage monitor.

See also: