From 011f0541a24829e42a47305a9fe30fc4ab37b0c7 Mon Sep 17 00:00:00 2001 From: ykisana Date: Sat, 14 Nov 2026 14:36:52 -0600 Subject: [PATCH] fix: re-block timed unblocks at the end registered with iOS The window registered with DeviceActivity drops the fraction of a second from the end, but the stored end kept it. Since 1.6 the monitor re-blocks by comparing the stored end to now, so when intervalDidEnd arrived within that fraction the unblock still looked active and apps stayed open until the next heartbeat (:00 or :30) or app launch. Round the stored end down to a whole second, as the registered end already is, so the stored end is never after the registered end. --- Normal/Services/TimedUnblockService.swift | 9 ++++++-- .../Services/BlockingScenarioTests.swift | 21 +++++++++++++++++++ .../Services/TimedUnblockServiceTests.swift | 3 ++- NormalTests/TestSupport/BlockingHarness.swift | 7 +++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Normal/Services/TimedUnblockService.swift b/Normal/Services/TimedUnblockService.swift index 9a01598..aee7035 100644 --- a/Normal/Services/TimedUnblockService.swift +++ b/Normal/Services/TimedUnblockService.swift @@ -64,7 +64,7 @@ final class TimedUnblockService { cancelMonitoring(activityName: activityName) let start = clock() - let endDate = start.addingTimeInterval(duration.timeInterval) + let endDate = Self.endDate(of: duration, from: start) try scheduleActivity(name: activityName, start: start, endDate: endDate) try persist( @@ -95,7 +95,7 @@ final class TimedUnblockService { cancelMonitoring(activityName: activityName) let start = clock() - let endDate = start.addingTimeInterval(duration.timeInterval) + let endDate = Self.endDate(of: duration, from: start) try scheduleActivity(name: activityName, start: start, endDate: endDate) try persist( @@ -280,6 +280,11 @@ final class TimedUnblockService { } } + private static func endDate(of duration: TimedUnblockDuration, from start: Date) -> Date { + let end = start.addingTimeInterval(duration.timeInterval).timeIntervalSinceReferenceDate + return Date(timeIntervalSinceReferenceDate: end.rounded(.down)) + } + private func scheduleActivity(name: String, start: Date, endDate: Date) throws { let schedule = DeviceActivityScheduleFactory.window(from: start, to: endDate) try activityCenter.startMonitoring( diff --git a/NormalTests/Services/BlockingScenarioTests.swift b/NormalTests/Services/BlockingScenarioTests.swift index 0f4b150..d101766 100644 --- a/NormalTests/Services/BlockingScenarioTests.swift +++ b/NormalTests/Services/BlockingScenarioTests.swift @@ -275,6 +275,27 @@ struct BlockingScenarioTests { #expect(h.blocked == h.apps(1, 2)) } + @Test func timedUnblockAllStartedMidSecondReblocksAtItsRegisteredEnd() throws { + let h = BlockingHarness() + h.blockAll() + h.passTime(to: h.at(10).addingTimeInterval(0.6)) + try h.unblockAllTimed(.thirtyMinutes) + + h.wake(at: try h.registeredEnd(of: SharedConstants.mainTimedUnblockActivityName)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func groupTimedUnblockStartedMidSecondReblocksAtItsRegisteredEnd() throws { + let h = BlockingHarness() + h.blockAll() + let social = h.group([1, 2]) + h.passTime(to: h.at(10).addingTimeInterval(0.6)) + try h.unblockGroupTimed(social, .oneHour) + + h.wake(at: try h.registeredEnd(of: SharedConstants.groupTimedUnblockActivityName(for: social.id))) + #expect(h.blocked == h.apps(1, 2, 3)) + } + @Test func blockingAGroupDuringTimedUnblockAllAppliesNowAndAfter() throws { let h = BlockingHarness() h.blockAll() diff --git a/NormalTests/Services/TimedUnblockServiceTests.swift b/NormalTests/Services/TimedUnblockServiceTests.swift index 98f168c..2c47805 100644 --- a/NormalTests/Services/TimedUnblockServiceTests.swift +++ b/NormalTests/Services/TimedUnblockServiceTests.swift @@ -123,7 +123,8 @@ struct TimedUnblockServiceTests { let dto = try #require(store.timedUnblocks.first) let start = try #require(dto.startDate) #expect(start >= before) - #expect(dto.endDate.timeIntervalSince(start) == 5700) + let length = dto.endDate.timeIntervalSince(start) + #expect(length > 5699 && length <= 5700) #expect(service.activeUnblockStartDates()[TimedUnblockService.mainID] == start) } diff --git a/NormalTests/TestSupport/BlockingHarness.swift b/NormalTests/TestSupport/BlockingHarness.swift index 6e81097..8880fae 100644 --- a/NormalTests/TestSupport/BlockingHarness.swift +++ b/NormalTests/TestSupport/BlockingHarness.swift @@ -1,7 +1,9 @@ +import DeviceActivity import FamilyControls import Foundation import ManagedSettings @testable import Normal +import Testing @MainActor final class BlockingHarness { @@ -81,6 +83,11 @@ final class BlockingHarness { Set(ids.map(TokenFixtures.app)) } + func registeredEnd(of activityName: String) throws -> Date { + let schedule = try #require(activity.registeredSchedule(for: DeviceActivityName(activityName))) + return try #require(Calendar.current.date(from: schedule.intervalEnd)) + } + func passTime(to date: Date) { clock.now = date }