diff --git a/Normal/Services/DeviceActivityScheduleFactory.swift b/Normal/Services/DeviceActivityScheduleFactory.swift index f7ad21d..a057c16 100644 --- a/Normal/Services/DeviceActivityScheduleFactory.swift +++ b/Normal/Services/DeviceActivityScheduleFactory.swift @@ -20,6 +20,14 @@ enum DeviceActivityScheduleFactory { ) } + static func heartbeat() -> DeviceActivitySchedule { + DeviceActivitySchedule( + intervalStart: DateComponents(minute: 0, second: 0), + intervalEnd: DateComponents(minute: 30, second: 0), + repeats: true + ) + } + static func daily(resetMinutes: Int, warningMinutes: Int) -> DeviceActivitySchedule { let startSeconds = resetMinutes * 60 let endSeconds = (startSeconds - 1 + 86400) % 86400 diff --git a/Normal/Services/TimedUnblockService.swift b/Normal/Services/TimedUnblockService.swift index 4ea45eb..ddc9377 100644 --- a/Normal/Services/TimedUnblockService.swift +++ b/Normal/Services/TimedUnblockService.swift @@ -271,6 +271,23 @@ final class TimedUnblockService { during: schedule, events: [:] ) + startHeartbeat() + } + + private func startHeartbeat() { + try? activityCenter.startMonitoring( + DeviceActivityName(SharedConstants.reblockHeartbeatActivityName), + during: DeviceActivityScheduleFactory.heartbeat(), + events: [:] + ) + } + + private func syncHeartbeat() { + if sharedStore.loadTimedUnblocks().isEmpty { + activityCenter.stopMonitoring([DeviceActivityName(SharedConstants.reblockHeartbeatActivityName)]) + } else { + startHeartbeat() + } } func refresh(screenTimeService: any ScreenTimeProviding) { @@ -299,6 +316,7 @@ final class TimedUnblockService { sharedStore.removeTimedUnblock(id: unblock.id) } } + syncHeartbeat() } private func reapplyShield( diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index 19ae551..f421a1f 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -12,6 +12,7 @@ final class NormalMonitor: DeviceActivityMonitor { private static let scheduleStartToleranceSeconds: TimeInterval = 60 override func intervalDidStart(for activity: DeviceActivityName) { + reblockExpiredUnblocks() let name = activity.rawValue if name == SharedConstants.usageLimitsActivityName { sharedStore.resetUsageDayIfStale() @@ -23,6 +24,7 @@ final class NormalMonitor: DeviceActivityMonitor { } override func intervalDidEnd(for activity: DeviceActivityName) { + reblockExpiredUnblocks() let name = activity.rawValue if name.hasPrefix("timedUnblock_") { handleTimedUnblockExpired(activityName: name) @@ -35,6 +37,7 @@ final class NormalMonitor: DeviceActivityMonitor { _ event: DeviceActivityEvent.Name, activity _: DeviceActivityName ) { + reblockExpiredUnblocks() guard let limit = credibleUsageLimit(eventName: event.rawValue, minutes: \.minutesPerDay) else { return } sharedStore.recordUsageState(.reached, for: limit.id) limitStore.syncDailyLimits(from: sharedStore) @@ -44,6 +47,7 @@ final class NormalMonitor: DeviceActivityMonitor { _ event: DeviceActivityEvent.Name, activity _: DeviceActivityName ) { + reblockExpiredUnblocks() guard let limit = credibleUsageLimit( eventName: event.rawValue, minutes: { $0.minutesPerDay - SharedConstants.usageLimitWarningMinutes } @@ -69,10 +73,20 @@ final class NormalMonitor: DeviceActivityMonitor { return limit } + private func reblockExpiredUnblocks() { + let now = Date.now + for dto in sharedStore.loadTimedUnblocks() where dto.endDate <= now { + reblock(dto) + } + } + private func handleTimedUnblockExpired(activityName: String) { - guard let dto = sharedStore.findTimedUnblock(activityName: activityName), - let selection = try? FamilyActivitySelection.fromData(dto.selectionData) - else { return } + guard let dto = sharedStore.findTimedUnblock(activityName: activityName) else { return } + reblock(dto) + } + + private func reblock(_ dto: TimedUnblockDTO) { + guard let selection = try? FamilyActivitySelection.fromData(dto.selectionData) else { return } let domains = gatedDomains(dto.customDomains) let allowing = activeSchedules(shouldBlock: false) diff --git a/NormalTests/Services/TimedUnblockServiceTests.swift b/NormalTests/Services/TimedUnblockServiceTests.swift index 3ac63ca..c7f0010 100644 --- a/NormalTests/Services/TimedUnblockServiceTests.swift +++ b/NormalTests/Services/TimedUnblockServiceTests.swift @@ -32,7 +32,10 @@ struct TimedUnblockServiceTests { ) #expect(screenTime.removeShieldOnAllCalled) - #expect(activity.startCalls.count == 1) + #expect(activity.startCalls.map(\.name.rawValue) == [ + SharedConstants.mainTimedUnblockActivityName, + SharedConstants.reblockHeartbeatActivityName, + ]) #expect(store.timedUnblocks.count == 1) #expect(service.isMainUnblockActive) } @@ -50,7 +53,10 @@ struct TimedUnblockServiceTests { ) #expect(screenTime.removeFromShieldsCalled) - #expect(activity.startCalls.count == 1) + #expect(activity.startCalls.map(\.name.rawValue) == [ + SharedConstants.groupTimedUnblockActivityName(for: groupId), + SharedConstants.reblockHeartbeatActivityName, + ]) #expect(store.timedUnblocks.count == 1) #expect(service.isGroupUnblockActive(groupId: groupId)) } @@ -327,6 +333,39 @@ struct TimedUnblockServiceTests { #expect(store.timedUnblocks.count == 1) } + @Test func heartbeatRepeatsEveryHalfHour() { + let heartbeat = DeviceActivityScheduleFactory.heartbeat() + #expect(heartbeat.intervalStart == DateComponents(minute: 0, second: 0)) + #expect(heartbeat.intervalEnd == DateComponents(minute: 30, second: 0)) + #expect(heartbeat.repeats) + } + + @Test func reconcileStopsHeartbeatOnceNoUnblocksRemain() { + let (service, activity, store) = makeService() + store.timedUnblocks = [expiredMainDTO()] + + service.reconcile(screenTimeService: FakeScreenTimeService()) + + #expect(activity.stopCalls.last == [DeviceActivityName(SharedConstants.reblockHeartbeatActivityName)]) + #expect(!activity.startCalls.map(\.name.rawValue).contains(SharedConstants.reblockHeartbeatActivityName)) + } + + @Test func reconcileRegistersHeartbeatWhileUnblocksArePending() throws { + let (service, activity, store) = makeService() + store.timedUnblocks = try [TimedUnblockDTO( + id: TimedUnblockService.mainID, + selectionData: FamilyActivitySelection().toData(), + endDate: .now.addingTimeInterval(.hours(1)), + activityName: SharedConstants.mainTimedUnblockActivityName, + isGroupUnblock: false + )] + + service.reconcile(screenTimeService: FakeScreenTimeService()) + + #expect(activity.startCalls.last?.name.rawValue == SharedConstants.reblockHeartbeatActivityName) + #expect(activity.startCalls.last?.schedule == DeviceActivityScheduleFactory.heartbeat()) + } + @Test func reconcileReblocksUnblockThatExpiredWhileAppWasDead() { let store = FakeSharedStore() store.timedUnblocks = [expiredMainDTO()] diff --git a/Shared/SharedConstants.swift b/Shared/SharedConstants.swift index b92fe54..5bd9c85 100644 --- a/Shared/SharedConstants.swift +++ b/Shared/SharedConstants.swift @@ -32,6 +32,8 @@ nonisolated enum SharedConstants { static let usageLimitsActivityName = "usageLimits_daily" + static let reblockHeartbeatActivityName = "reblockHeartbeat" + static let usageLimitWarningMinutes = 3 private static let usageEventPrefix = "usage_"