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.
includesPastActivity is explicitly set to false.
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.
startMonitoring may 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 eventDidReachThreshold as 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 eventDidReachThreshold fires prematurely on iOS 26?
I would appreciate confirmation from Apple engineers or feedback from other developers who have tested a similar approach.