I am investigating premature DeviceActivityMonitor.eventDidReachThreshold callbacks on physical devices running iOS 26.x.
I have also observed similar overcounting behavior on iOS 18.x. In one repeatable test, an all-activity event configured with a 10-minute threshold fired after approximately 5 minutes of actual unlocked usage.
The event was created with:
- A single completion threshold.
includesPastActivity: false.- A nonrepeating
DeviceActivitySchedule. - A unique activity and event identity for each monitoring generation.
However, eventDidReachThreshold could still arrive significantly earlier than expected.
Defensive mitigation
I have been testing a defensive mechanism that treats eventDidReachThreshold only as a wake-up signal, rather than authoritative proof that the configured usage duration has elapsed.
When the callback arrives, the monitor extension independently validates the duration against locally persisted timing state.
The flow is:
- Each logical work cycle has a unique cycle identifier and generation number.
- The app stores a local timing anchor when monitoring begins or resumes.
- When the threshold callback arrives, the extension verifies:
- The cycle identifier.
- The generation number.
- The activity name.
- The current application state.
- The extension calculates a locally trusted elapsed duration.
- If the local duration has not reached the configured duration:
- It does not send a notification.
- It does not apply any user-visible action.
- It persists only the locally trusted progress.
- It increments the generation number.
- It stops the previous monitor.
- It registers a new event for only the locally remaining duration.
- Completion is accepted only when the locally calculated duration is due.
- Delayed callbacks from previous generations are ignored.
Simplified pseudocode:
override func eventDidReachThreshold(
_ event: DeviceActivityEvent.Name,
activity: DeviceActivityName
) {
let state = loadPersistedState()
guard eventMatchesCurrentGeneration(
event: event,
activity: activity,
state: state
) else {
// Ignore stale or duplicated callbacks.
return
}
let now = Date()
let trustedElapsed = calculateLocallyAccountedElapsed(
state: state,
now: now
)
let tolerance: TimeInterval = 3
if trustedElapsed + tolerance < state.configuredDuration {
let remaining = state.configuredDuration - trustedElapsed
var nextState = state
nextState.confirmedElapsed = trustedElapsed
nextState.generation += 1
// Persist the new generation before replacing the monitor.
persistAtomically(nextState)
center.stopMonitoring([activity])
let nextActivity = makeActivityName(
cycleID: nextState.cycleID,
generation: nextState.generation
)
let completionEvent = DeviceActivityEvent(
threshold: normalizedDateComponents(remaining),
includesPastActivity: false
)
do {
try center.startMonitoring(
nextActivity,
during: makeNonRepeatingSchedule(),
events: [
makeCompletionEventName(nextState): completionEvent
]
)
} catch {
// Persist a recoverable unavailable state.
recordMonitoringFailure(error)
}
return
}
transitionToCompletedState()
scheduleUserNotification()
}
Durations are normalized before creating the event:
func normalizedDateComponents(
_ duration: TimeInterval
) -> DateComponents {
let seconds = max(1, Int(duration))
return DateComponents(
minute: seconds / 60,
second: seconds % 60
)
}
This avoids using values such as second: 300.
Example
For a configured duration of 10 minutes:
- DeviceActivity incorrectly delivers the completion callback after approximately 5 minutes.
- Local accounting reports only approximately 5 minutes.
- No notification or other user-visible action is performed.
- The previous monitor is replaced with a new generation configured for the remaining approximately 5 minutes.
- Completion is accepted only after the locally trusted timing state is due.
This mechanism has so far prevented premature DeviceActivity callbacks from producing premature notifications during my physical-device testing on iOS 26.x.
Additional precautions
The implementation also uses the following precautions:
- Only one completion event is registered instead of multiple minute checkpoints.
includesPastActivityis explicitly set tofalse.- Every replacement monitor has a new generation identity.
- Generation state is persisted before the old monitor is replaced.
- Callbacks from an old cycle, generation, or activity name are ignored.
- The extension performs only small, bounded state updates.
- User-visible actions occur only after local validation succeeds.
Limitations
This is a defensive workaround, not a fix for the underlying DeviceActivity or Screen Time accounting issue.
Known limitations include:
- It cannot prevent iOS from delivering an incorrect callback.
- If the system never delivers another callback, completion may be delayed or missed.
- If every new event immediately fires, repeated re-registration may occur.
startMonitoringmay fail if the system considers the activities too numerous or too tightly scheduled.- Local unlocked-time accounting depends on reliable lock and unlock observations.
- Wall-clock calculations must consider manual system-time changes.
- The approach cannot correct Screen Time’s internal activity data.
- For modes that intentionally count locked time, absolute local-notification scheduling may be more reliable and may avoid DeviceActivity thresholds entirely.
All processing in this mitigation occurs on-device. It does not require uploading activity tokens, Screen Time data, user identifiers, or diagnostic logs. The implementation uses only public APIs.
Questions for Apple
- Is treating
eventDidReachThresholdas a wake-up signal and validating it against locally persisted timing state an acceptable design? - Is stopping the current monitor and registering a new generation for only the locally remaining duration from the monitor extension considered a supported recovery pattern?
- Are there documented or recommended limits, rate controls, or backoff requirements for this type of defensive re-registration?
- Is there a more reliable supported API for usage-based completion when
eventDidReachThresholdfires prematurely on iOS 26?
I would appreciate confirmation from Apple engineers or feedback from other developers who have tested a similar approach.