Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Normal/Models/BlockSchedule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,19 @@ final class BlockSchedule: Identifiable {
}

func isActive(at now: Date, calendar: Calendar = .current) -> Bool {
guard isEnabled else { return false }
activeWindowStart(at: now, calendar: calendar) != nil
}

func activeWindowStart(at now: Date, calendar: Calendar = .current) -> Date? {
guard isEnabled else { return nil }
return ScheduleWindow.start(
containing: now,
startHour: startHour,
startMinute: startMinute,
durationMinutes: effectiveDurationMinutes,
weekdays: weekdays,
calendar: calendar
) != nil
)
}

func toDTO() -> ScheduleDTO? {
Expand Down
4 changes: 2 additions & 2 deletions Normal/Services/ScheduleService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ final class ScheduleService {
_ schedule: BlockSchedule,
screenTimeService: any ScreenTimeProviding
) {
guard schedule.isActive(at: .now) else { return }
guard let windowStart = schedule.activeWindowStart(at: .now) else { return }
let domains = effectiveDomains(schedule)
if schedule.shouldBlock {
guard !sharedStore.isUnblockAllInEffect() else { return }
guard !sharedStore.suppressesSchedule(windowStart: windowStart) else { return }
screenTimeService.addToShields(selection: schedule.selection, customDomains: domains)
} else {
screenTimeService.removeFromShields(selection: schedule.selection, customDomains: domains)
Expand Down
6 changes: 4 additions & 2 deletions NormalMonitor/NormalMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@ final class NormalMonitor: DeviceActivityMonitor {

private func handleScheduleIntervalStart(activityName: String) {
guard let schedule = findSchedule(activityName: activityName),
schedule.isActive(at: .now.addingTimeInterval(Self.scheduleStartToleranceSeconds)),
let windowStart = schedule.activeWindowStart(
at: .now.addingTimeInterval(Self.scheduleStartToleranceSeconds)
),
let selection = try? FamilyActivitySelection.fromData(schedule.selectionData)
else { return }

guard sharedStore.resolveScheduleStart() == .apply else { return }
guard sharedStore.resolveScheduleStart(windowStart: windowStart) == .apply else { return }

let domains = gatedDomains(schedule.customDomains)
if schedule.shouldBlock {
Expand Down
22 changes: 22 additions & 0 deletions NormalTests/Services/ScheduleServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,28 @@ struct ScheduleServiceTests {
"Foreground re-application must not consume the override; only a fresh start does")
}

@Test func overrideSetBeforeWindowStartedDoesNotSuppressIt() throws {
let (service, _, store) = makeService()
let screenTime = FakeScreenTimeService()
store.scheduleOverrideActive = true
store.scheduleOverrideSince = .now.addingTimeInterval(-.hours(3))

try service.sync(activeNowSchedule(), screenTimeService: screenTime)

#expect(screenTime.addToShieldsCalled)
}

@Test func legacyOverrideWithoutTimestampSuppressesActiveSchedule() throws {
let (service, _, store) = makeService()
let screenTime = FakeScreenTimeService()
store.scheduleOverrideActive = true

try service.sync(activeNowSchedule(), screenTimeService: screenTime)

#expect(!screenTime.addToShieldsCalled)
#expect(store.isScheduleOverrideActive())
}

@Test func setScheduleOverrideWritesFlag() {
let (service, _, store) = makeService()

Expand Down
50 changes: 46 additions & 4 deletions NormalTests/Shared/SharedStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,24 +116,66 @@ struct SharedStoreTests {

@Test func resolveScheduleStartAppliesWhenNothingActive() {
let (store, _) = makeStore()
#expect(store.resolveScheduleStart() == .apply)
#expect(store.resolveScheduleStart(windowStart: .now) == .apply)
}

@Test func resolveScheduleStartConsumesPermanentOverrideThenApplies() {
let (store, _) = makeStore()
store.setScheduleOverrideActive(true)
let nextWindow = Date.now.addingTimeInterval(.minutes(1))

#expect(store.resolveScheduleStart() == .apply, "The start proceeds (override ends here)")
#expect(store.resolveScheduleStart(windowStart: nextWindow) == .apply, "The start proceeds (override ends here)")
#expect(!store.isScheduleOverrideActive(), "...and the one-shot override is consumed")
#expect(store.resolveScheduleStart() == .apply, "Subsequent starts apply normally")
#expect(store.resolveScheduleStart(windowStart: nextWindow) == .apply, "Subsequent starts apply normally")
}

@Test func refiredStartOfWindowBegunBeforeOverrideSkipsWithoutConsuming() {
let (store, _) = makeStore()
let runningWindow = Date.now.addingTimeInterval(-.hours(1))
store.setScheduleOverrideActive(true)

#expect(store.resolveScheduleStart(windowStart: runningWindow) == .skip)
#expect(store.resolveScheduleStart(windowStart: runningWindow) == .skip)
#expect(store.isScheduleOverrideActive())
#expect(store.suppressesSchedule(windowStart: runningWindow))
}

@Test func overrideDoesNotSuppressWindowStartedAfterIt() {
let (store, _) = makeStore()
store.setScheduleOverrideActive(true)

#expect(!store.suppressesSchedule(windowStart: .now.addingTimeInterval(.minutes(1))))
}

@Test func legacyOverrideWithoutTimestampKeepsOneShotSemantics() {
let (store, defaults) = makeStore()
defaults.set(true, forKey: SharedConstants.DefaultsKey.scheduleOverride)
let runningWindow = Date.now.addingTimeInterval(-.hours(1))

#expect(store.suppressesSchedule(windowStart: runningWindow))
#expect(store.resolveScheduleStart(windowStart: runningWindow) == .apply)
#expect(!store.isScheduleOverrideActive())
}

@Test func settingOverrideWritesLegacyFlagAndTimestamp() {
let (store, defaults) = makeStore()

store.setScheduleOverrideActive(true)
#expect(defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride))
#expect(store.loadScheduleOverrideSince() != nil)

store.setScheduleOverrideActive(false)
#expect(!defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride))
#expect(store.loadScheduleOverrideSince() == nil)
}

@Test func resolveScheduleStartSkipsDuringTimedUnblockWithoutConsumingOverride() {
let (store, _) = makeStore()
store.upsertTimedUnblock(makeDTO(id: "main"))
store.setScheduleOverrideActive(true)

#expect(store.resolveScheduleStart() == .skip, "A live timed unblock wins for its whole window")
#expect(store.resolveScheduleStart(windowStart: .now.addingTimeInterval(.minutes(1))) == .skip,
"A live timed unblock wins for its whole window")
#expect(store.isScheduleOverrideActive(),
"A timed-unblock skip must not consume the permanent override")
}
Expand Down
6 changes: 6 additions & 0 deletions NormalTests/TestSupport/FakeSharedStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable {
var timedUnblocks: [TimedUnblockDTO] = []
var schedules: [ScheduleDTO] = []
var scheduleOverrideActive = false
var scheduleOverrideSince: Date?
var customDomainsEnabled = false
var usageLimits: [UsageLimitDTO] = []
var usageDayState: UsageDayStateDTO = .empty
Expand Down Expand Up @@ -50,6 +51,11 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable {

func setScheduleOverrideActive(_ active: Bool) {
scheduleOverrideActive = active
scheduleOverrideSince = active ? .now : nil
}

func loadScheduleOverrideSince() -> Date? {
scheduleOverrideSince
}

func isCustomDomainsEnabled() -> Bool {
Expand Down
1 change: 1 addition & 0 deletions Shared/SharedConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ nonisolated enum SharedConstants {
static let timedUnblocks = "timedUnblocks_v1"
static let schedules = "schedules_v1"
static let scheduleOverride = "scheduleOverride_v1"
static let scheduleOverrideSince = "scheduleOverrideSince_v1"
static let customDomainsEnabled = "customDomainsEnabled_v1"
static let widgetGroups = "widgetGroups_v1"
static let widgetKeyTypes = "widgetKeyTypes_v1"
Expand Down
6 changes: 5 additions & 1 deletion Shared/SharedDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,18 @@ extension ScheduleDTO {
}

func isActive(at date: Date, calendar: Calendar = .current) -> Bool {
activeWindowStart(at: date, calendar: calendar) != nil
}

func activeWindowStart(at date: Date, calendar: Calendar = .current) -> Date? {
ScheduleWindow.start(
containing: date,
startHour: startHour,
startMinute: startMinute,
durationMinutes: durationMinutes,
weekdays: weekdays,
calendar: calendar
) != nil
)
}

func endApplies(on date: Date, calendar: Calendar = .current) -> Bool {
Expand Down
14 changes: 14 additions & 0 deletions Shared/SharedStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ struct SharedStore: SharedStoreProviding, Sendable {

func setScheduleOverrideActive(_ active: Bool) {
defaults.set(active, forKey: SharedConstants.DefaultsKey.scheduleOverride)
if active {
defaults.set(
Date.now.timeIntervalSinceReferenceDate,
forKey: SharedConstants.DefaultsKey.scheduleOverrideSince
)
} else {
defaults.removeObject(forKey: SharedConstants.DefaultsKey.scheduleOverrideSince)
}
}

func loadScheduleOverrideSince() -> Date? {
guard let value = defaults.object(forKey: SharedConstants.DefaultsKey.scheduleOverrideSince) as? Double
else { return nil }
return Date(timeIntervalSinceReferenceDate: value)
}

func isCustomDomainsEnabled() -> Bool {
Expand Down
18 changes: 15 additions & 3 deletions Shared/SharedStoreProviding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ nonisolated protocol SharedStoreProviding: Sendable {
func loadSchedules() -> [ScheduleDTO]
func isScheduleOverrideActive() -> Bool
func setScheduleOverrideActive(_ active: Bool)
func loadScheduleOverrideSince() -> Date?
func isCustomDomainsEnabled() -> Bool
func setCustomDomainsEnabled(_ enabled: Bool)
func saveUsageLimits(_ limits: [UsageLimitDTO])
Expand All @@ -35,13 +36,24 @@ extension SharedStoreProviding {
isMainTimedUnblockActive() || isScheduleOverrideActive()
}

func resolveScheduleStart() -> ScheduleStartDecision {
func suppressesSchedule(windowStart: Date) -> Bool {
if isMainTimedUnblockActive() {
return true
}
guard isScheduleOverrideActive() else { return false }
guard let since = loadScheduleOverrideSince() else { return true }
return windowStart < since
}

func resolveScheduleStart(windowStart: Date) -> ScheduleStartDecision {
if isMainTimedUnblockActive() {
return .skip
}
if isScheduleOverrideActive() {
setScheduleOverrideActive(false)
guard isScheduleOverrideActive() else { return .apply }
if let since = loadScheduleOverrideSince(), windowStart < since {
return .skip
}
setScheduleOverrideActive(false)
return .apply
}

Expand Down
Loading