diff --git a/Normal/App/ContentView.swift b/Normal/App/ContentView.swift index 0977cf2..0ecf2b7 100644 --- a/Normal/App/ContentView.swift +++ b/Normal/App/ContentView.swift @@ -5,9 +5,11 @@ struct ContentView: View { @Environment(ScreenTimeService.self) private var screenTimeService @Environment(OnboardingService.self) private var onboardingService @Environment(TimedUnblockService.self) private var timedUnblockService + @Environment(ScheduleService.self) private var scheduleService @Environment(EmergencyUnblockService.self) private var emergencyUnblockService @Environment(\.scenePhase) private var scenePhase @Query private var allSettings: [Settings] + @Query private var schedules: [BlockSchedule] @State private var selectedTab: AppTab = .home @State private var navigationCoordinator = NavigationCoordinator() @@ -34,6 +36,7 @@ struct ContentView: View { Task { await screenTimeService.checkAuthorizationStatus() } screenTimeService.notifyUpdate() timedUnblockService.refresh() + reregisterSchedules() } } .onAppear(perform: onAppear) @@ -52,10 +55,16 @@ struct ContentView: View { await screenTimeService.checkAuthorizationStatus() if settings?.hasCompletedOnboarding == true { _ = await screenTimeService.ensureAuthorized() + reregisterSchedules() } } } + private func reregisterSchedules() { + guard settings?.hasCompletedOnboarding == true else { return } + scheduleService.registerAll(schedules, screenTimeService: screenTimeService) + } + private func onOnboardingCompleted(_: Bool, _ isActive: Bool) { if !isActive, settings?.hasCompletedOnboarding != true { settings?.hasCompletedOnboarding = true diff --git a/Normal/Models/BlockSchedule.swift b/Normal/Models/BlockSchedule.swift index cba2ec4..672db23 100644 --- a/Normal/Models/BlockSchedule.swift +++ b/Normal/Models/BlockSchedule.swift @@ -79,6 +79,25 @@ final class BlockSchedule: Identifiable { self.sortIndex = sortIndex } + func isActive(at now: Date, calendar: Calendar = .current) -> Bool { + guard isEnabled else { return false } + + let startOfToday = calendar.startOfDay(for: now) + for dayOffset in [0, -1] { + guard let day = calendar.date(byAdding: .day, value: dayOffset, to: startOfToday), + let start = calendar.date( + bySettingHour: startHour, minute: startMinute, second: 0, of: day + ) + else { continue } + let end = start + .minutes(durationMinutes) + if weekdays.contains(calendar.component(.weekday, from: start)), + now >= start, now < end { + return true + } + } + return false + } + func toDTO() -> ScheduleDTO? { guard let data = try? selection.toData() else { return nil } return ScheduleDTO( diff --git a/Normal/Resources/Localizable.xcstrings b/Normal/Resources/Localizable.xcstrings index c346391..0a37cda 100644 --- a/Normal/Resources/Localizable.xcstrings +++ b/Normal/Resources/Localizable.xcstrings @@ -344,6 +344,10 @@ "comment" : "The text for a button that finalizes the selection of apps for a family member.", "isCommentAutoGenerated" : true }, + "Duration must be at least %lld minutes." : { + "comment" : "An error message displayed in the checkout view when the duration of a schedule is too short.", + "isCommentAutoGenerated" : true + }, "Duration Type" : { "comment" : "A label displayed above the picker for the duration type of a schedule.", "isCommentAutoGenerated" : true diff --git a/Normal/Services/DeviceActivityScheduleFactory.swift b/Normal/Services/DeviceActivityScheduleFactory.swift new file mode 100644 index 0000000..5db33b5 --- /dev/null +++ b/Normal/Services/DeviceActivityScheduleFactory.swift @@ -0,0 +1,22 @@ +import DeviceActivity +import Foundation + +enum DeviceActivityScheduleFactory { + static let minimumInterval: TimeInterval = .minutes(15) + + private static let boundaryMargin: TimeInterval = 1 + + static func window( + from start: Date, + to end: Date, + calendar: Calendar = .current + ) -> DeviceActivitySchedule { + let flooredEnd = max(end, start.addingTimeInterval(minimumInterval + boundaryMargin)) + let fields: Set = [.year, .month, .day, .hour, .minute, .second] + return DeviceActivitySchedule( + intervalStart: calendar.dateComponents(fields, from: start), + intervalEnd: calendar.dateComponents(fields, from: flooredEnd), + repeats: false + ) + } +} diff --git a/Normal/Services/ScheduleService.swift b/Normal/Services/ScheduleService.swift index 4b61a3b..4050264 100644 --- a/Normal/Services/ScheduleService.swift +++ b/Normal/Services/ScheduleService.swift @@ -31,6 +31,17 @@ final class ScheduleService { let deviceSchedule = makeDeviceSchedule(for: schedule) try activityCenter.startMonitoring(activityName, during: deviceSchedule, events: [:]) + applyIfActiveNow(schedule, screenTimeService: screenTimeService) + } + + func registerAll( + _ schedules: [BlockSchedule], + screenTimeService: any ScreenTimeProviding + ) { + syncAllToSharedStore(schedules) + for schedule in schedules where schedule.isEnabled { + try? sync(schedule, screenTimeService: screenTimeService) + } } func remove( @@ -49,6 +60,23 @@ final class ScheduleService { try sync(schedule, screenTimeService: screenTimeService) } + func setScheduleOverride(_ active: Bool) { + sharedStore.setScheduleOverrideActive(active) + } + + func disableAll( + _ schedules: [BlockSchedule], + screenTimeService: any ScreenTimeProviding + ) { + for schedule in schedules where schedule.isEnabled { + schedule.isEnabled = false + } + for schedule in schedules { + try? sync(schedule, screenTimeService: screenTimeService) + } + syncAllToSharedStore(schedules) + } + func syncAllToSharedStore(_ schedules: [BlockSchedule]) { sharedStore.saveSchedules(schedules.compactMap { $0.toDTO() }) } @@ -66,6 +94,19 @@ final class ScheduleService { DeviceActivityName(SharedConstants.scheduleActivityName(for: schedule.id)) } + private func applyIfActiveNow( + _ schedule: BlockSchedule, + screenTimeService: any ScreenTimeProviding + ) { + guard schedule.isActive(at: .now) else { return } + if schedule.shouldBlock { + guard !sharedStore.isUnblockAllInEffect() else { return } + screenTimeService.addToShields(selection: schedule.selection) + } else { + screenTimeService.removeFromShields(selection: schedule.selection) + } + } + private func liftBlockIfNeeded( _ schedule: BlockSchedule, screenTimeService: any ScreenTimeProviding diff --git a/Normal/Services/TimedUnblockService.swift b/Normal/Services/TimedUnblockService.swift index 912a9f0..0f807ca 100644 --- a/Normal/Services/TimedUnblockService.swift +++ b/Normal/Services/TimedUnblockService.swift @@ -52,9 +52,11 @@ final class TimedUnblockService { cancelMonitoring(activityName: activityName) cancelAllGroupUnblocks() - let endDate = Date.now.addingTimeInterval(duration.timeInterval) + let start = Date.now + let endDate = start.addingTimeInterval(duration.timeInterval) screenTimeService.removeShieldOnAll(blockAllPreventsAppDelete: blockAllPreventsAppDelete) - try scheduleActivity(name: activityName, endDate: endDate) + sharedStore.setScheduleOverrideActive(false) + try scheduleActivity(name: activityName, start: start, endDate: endDate) try persist( id: Self.mainID, @@ -76,9 +78,10 @@ final class TimedUnblockService { let activityName = SharedConstants.groupTimedUnblockActivityName(for: groupId) cancelMonitoring(activityName: activityName) - let endDate = Date.now.addingTimeInterval(duration.timeInterval) + let start = Date.now + let endDate = start.addingTimeInterval(duration.timeInterval) screenTimeService.removeFromShields(selection: selection) - try scheduleActivity(name: activityName, endDate: endDate) + try scheduleActivity(name: activityName, start: start, endDate: endDate) try persist( id: id, @@ -220,14 +223,8 @@ final class TimedUnblockService { } } - private func scheduleActivity(name: String, endDate: Date) throws { - let calendar = Calendar.current - let fields: Set = [.year, .month, .day, .hour, .minute, .second] - let schedule = DeviceActivitySchedule( - intervalStart: calendar.dateComponents(fields, from: .now), - intervalEnd: calendar.dateComponents(fields, from: endDate), - repeats: false - ) + private func scheduleActivity(name: String, start: Date, endDate: Date) throws { + let schedule = DeviceActivityScheduleFactory.window(from: start, to: endDate) try activityCenter.startMonitoring( DeviceActivityName(name), during: schedule, diff --git a/Normal/Views/Home/MainBlockButtonView.swift b/Normal/Views/Home/MainBlockButtonView.swift index 8d6c0b6..bf8ee6b 100644 --- a/Normal/Views/Home/MainBlockButtonView.swift +++ b/Normal/Views/Home/MainBlockButtonView.swift @@ -5,6 +5,7 @@ import SwiftUI struct MainBlockButtonView: View { @Environment(ScreenTimeService.self) private var screenTimeService @Environment(TimedUnblockService.self) private var timedUnblockService + @Environment(ScheduleService.self) private var scheduleService @Environment(AppReviewService.self) private var appReviewService @Environment(\.requestReview) private var requestReview @Query private var allSettings: [Settings] @@ -47,6 +48,7 @@ struct MainBlockButtonView: View { blockAllPreventsAppDelete: settings.blockAllPreventsAppDelete ) timedUnblockService.clearAll() + scheduleService.setScheduleOverride(false) } } label: { HStack { @@ -104,6 +106,7 @@ struct MainBlockButtonView: View { screenTimeService.removeShieldOnAll( blockAllPreventsAppDelete: settings.blockAllPreventsAppDelete ) + scheduleService.setScheduleOverride(true) recordUnblockForReview() } ) diff --git a/Normal/Views/Schedules/ScheduleFormSheet.swift b/Normal/Views/Schedules/ScheduleFormSheet.swift index 1f5516d..d58fd9b 100644 --- a/Normal/Views/Schedules/ScheduleFormSheet.swift +++ b/Normal/Views/Schedules/ScheduleFormSheet.swift @@ -25,11 +25,17 @@ struct ScheduleFormSheet: View { private var isNew: Bool { existing == nil } + private static let minimumDurationMinutes = 15 + + private var isDurationTooShort: Bool { + isTimed && computedDurationMinutes < Self.minimumDurationMinutes + } + private var canSave: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && selection.count > 0 && !selectedWeekdays.isEmpty - && (!isTimed || computedDurationMinutes > 0) + && !isDurationTooShort } private var computedDurationMinutes: Int { @@ -151,7 +157,12 @@ struct ScheduleFormSheet: View { Text("Time") } footer: { if isTimed { - Text("Duration: \(formattedComputedDuration)") + if isDurationTooShort { + Text("Duration must be at least \(Self.minimumDurationMinutes) minutes.") + .foregroundStyle(.red) + } else { + Text("Duration: \(formattedComputedDuration)") + } } } } diff --git a/Normal/Views/Settings/SettingsView.swift b/Normal/Views/Settings/SettingsView.swift index e7a6149..2d60e0f 100644 --- a/Normal/Views/Settings/SettingsView.swift +++ b/Normal/Views/Settings/SettingsView.swift @@ -4,9 +4,11 @@ import SwiftUI struct SettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(ScreenTimeService.self) private var screenTimeService + @Environment(ScheduleService.self) private var scheduleService @Environment(EmergencyUnblockService.self) private var emergencyUnblockService @Query private var allSettings: [Settings] @Query private var keys: [Key] + @Query private var schedules: [BlockSchedule] @State private var showConfirmation = false @State private var showSuccessAlert = false @@ -70,6 +72,7 @@ struct SettingsView: View { private func performEmergencyUnblock() { emergencyUnblockService.record(into: settings) screenTimeService.removeShieldOnAll(blockAllPreventsAppDelete: true) + scheduleService.disableAll(schedules, screenTimeService: screenTimeService) showSuccessAlert = true } } diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index 150bca8..48f4f47 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -49,6 +49,8 @@ final class NormalMonitor: DeviceActivityMonitor { let selection = try? FamilyActivitySelection.fromData(schedule.selectionData) else { return } + guard sharedStore.resolveScheduleStart() == .apply else { return } + if schedule.shouldBlock { store.unionShields(with: selection) } else { diff --git a/NormalTests/Services/ScheduleServiceTests.swift b/NormalTests/Services/ScheduleServiceTests.swift index 4fd6109..8901208 100644 --- a/NormalTests/Services/ScheduleServiceTests.swift +++ b/NormalTests/Services/ScheduleServiceTests.swift @@ -1,4 +1,5 @@ import FamilyControls +import Foundation @testable import Normal import Testing @@ -100,4 +101,190 @@ struct ScheduleServiceTests { #expect(store.schedules.count == 1) #expect(store.schedules.first?.startHour == 9) } + + @Test func registerAllReRegistersEnabledOnlyAndPersistsAll() { + let (service, activity, store) = makeService() + let screenTime = FakeScreenTimeService() + let enabled = makeSchedule(isEnabled: true) + let disabled = makeSchedule(isEnabled: false) + + service.registerAll([enabled, disabled], screenTimeService: screenTime) + + #expect(store.schedules.count == 2, "All schedules persisted to the shared store") + #expect(activity.startCalls.count == 1, "Only the enabled schedule is (re)registered") + } + + // MARK: - "Unblock all" overrides + + private func activeNowSchedule(shouldBlock: Bool = true) -> BlockSchedule { + let now = Date() + let calendar = Calendar.current + return BlockSchedule( + name: "ActiveNow", + selection: FamilyActivitySelection(), + startHour: calendar.component(.hour, from: now), + startMinute: 0, + durationMinutes: 120, + weekdays: [calendar.component(.weekday, from: now)], + shouldBlock: shouldBlock, + isTimed: true, + isEnabled: true + ) + } + + private func activeMainUnblock() -> TimedUnblockDTO { + try! TimedUnblockDTO( + id: TimedUnblockService.mainID, + selectionData: FamilyActivitySelection().toData(), + endDate: .now.addingTimeInterval(.hours(1)), + activityName: SharedConstants.mainTimedUnblockActivityName, + isGroupUnblock: false + ) + } + + @Test func activeScheduleBlocksWhenNoUnblockInEffect() throws { + let (service, _, _) = makeService() + let screenTime = FakeScreenTimeService() + + try service.sync(activeNowSchedule(), screenTimeService: screenTime) + + #expect(screenTime.addToShieldsCalled, "An active block schedule applies when nothing overrides it") + } + + @Test func activeScheduleSuppressedDuringTimedUnblock() throws { + let (service, _, store) = makeService() + let screenTime = FakeScreenTimeService() + store.timedUnblocks = [activeMainUnblock()] + + try service.sync(activeNowSchedule(), screenTimeService: screenTime) + + #expect(!screenTime.addToShieldsCalled, "A live timed unblock-all must override schedule blocking") + } + + @Test func activeScheduleSuppressedDuringPermanentOverride() throws { + let (service, _, store) = makeService() + let screenTime = FakeScreenTimeService() + store.setScheduleOverrideActive(true) + + try service.sync(activeNowSchedule(), screenTimeService: screenTime) + + #expect(!screenTime.addToShieldsCalled, "A permanent unblock-all override must suppress schedule blocking") + } + + @Test func reapplyDoesNotConsumePermanentOverride() throws { + let (service, _, store) = makeService() + let screenTime = FakeScreenTimeService() + store.setScheduleOverrideActive(true) + + try service.sync(activeNowSchedule(), screenTimeService: screenTime) + + #expect(store.isScheduleOverrideActive(), + "Foreground re-application must not consume the override; only a fresh start does") + } + + @Test func setScheduleOverrideWritesFlag() { + let (service, _, store) = makeService() + + service.setScheduleOverride(true) + #expect(store.isScheduleOverrideActive()) + + service.setScheduleOverride(false) + #expect(!store.isScheduleOverrideActive()) + } + + @Test func disableAllTogglesEveryScheduleOff() { + let (service, _, _) = makeService() + let screenTime = FakeScreenTimeService() + let first = makeSchedule(isEnabled: true) + let second = makeSchedule(isEnabled: true) + + service.disableAll([first, second], screenTimeService: screenTime) + + #expect(!first.isEnabled) + #expect(!second.isEnabled) + } + + @Test func disableAllStopsMonitoringAndLiftsBlocks() { + let (service, activity, store) = makeService() + let screenTime = FakeScreenTimeService() + let schedule = makeSchedule(shouldBlock: true, isEnabled: true) + + service.disableAll([schedule], screenTimeService: screenTime) + + #expect(activity.startCalls.isEmpty, "Disabled schedules are not monitored") + #expect(!activity.stopCalls.isEmpty, "Monitoring is stopped for the disabled schedule") + #expect(screenTime.removeFromShieldsCalled, "A blocking schedule's shield is lifted when disabled") + #expect(store.schedules.count == 1, "The disabled schedule is still persisted to the shared store") + } + + // MARK: - isActive(at:) + + private static let utc: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .current + return calendar + }() + + private func date(weekday: Int, hour: Int, minute: Int) -> Date { + var components = DateComponents() + components.year = 2026 + components.month = 6 + components.hour = hour + components.minute = minute + for day in 1 ... 7 { + components.day = day + if let candidate = Self.utc.date(from: components), + Self.utc.component(.weekday, from: candidate) == weekday { + return candidate + } + } + return Self.utc.date(from: components)! + } + + private func schedule( + startHour: Int, + durationMinutes: Int, + weekdays: Set, + isEnabled: Bool = true + ) -> BlockSchedule { + BlockSchedule( + name: "S", + selection: FamilyActivitySelection(), + startHour: startHour, startMinute: 0, + durationMinutes: durationMinutes, + weekdays: weekdays, + shouldBlock: true, + isTimed: true, + isEnabled: isEnabled + ) + } + + @Test func isActiveTrueWithinSameDayWindow() { + let s = schedule(startHour: 9, durationMinutes: 60, weekdays: [4]) + #expect(s.isActive(at: date(weekday: 4, hour: 9, minute: 30), calendar: Self.utc)) + } + + @Test func isActiveFalseOutsideWindowOrWrongDay() { + let s = schedule(startHour: 9, durationMinutes: 60, weekdays: [4]) + #expect(!s.isActive(at: date(weekday: 4, hour: 10, minute: 30), calendar: Self.utc)) + #expect(!s.isActive(at: date(weekday: 5, hour: 9, minute: 30), calendar: Self.utc)) + } + + @Test func isActiveFalseWhenDisabled() { + let s = schedule(startHour: 9, durationMinutes: 60, weekdays: [4], isEnabled: false) + #expect(!s.isActive(at: date(weekday: 4, hour: 9, minute: 30), calendar: Self.utc)) + } + + @Test func isActiveHandlesMidnightWrap() { + // Wed 23:00 for 3h → ends Thu 02:00. + let s = schedule(startHour: 23, durationMinutes: 180, weekdays: [4]) + #expect(s.isActive(at: date(weekday: 4, hour: 23, minute: 30), calendar: Self.utc), + "Active late on the start day") + #expect(s.isActive(at: date(weekday: 5, hour: 1, minute: 0), calendar: Self.utc), + "Active early on the next day") + #expect(!s.isActive(at: date(weekday: 5, hour: 3, minute: 0), calendar: Self.utc), + "Inactive after the wrapped end") + #expect(!s.isActive(at: date(weekday: 4, hour: 22, minute: 0), calendar: Self.utc), + "Inactive before the start") + } } diff --git a/NormalTests/Services/TimedUnblockServiceTests.swift b/NormalTests/Services/TimedUnblockServiceTests.swift index a71b658..35b41f3 100644 --- a/NormalTests/Services/TimedUnblockServiceTests.swift +++ b/NormalTests/Services/TimedUnblockServiceTests.swift @@ -1,3 +1,4 @@ +import DeviceActivity import FamilyControls import Foundation @testable import Normal @@ -96,6 +97,21 @@ struct TimedUnblockServiceTests { #expect(!service.isGroupUnblockActive(groupId: groupId)) } + @Test func startMainClearsPermanentScheduleOverride() throws { + let (service, _, store) = makeService() + let screenTime = FakeScreenTimeService() + store.setScheduleOverrideActive(true) + + try service.startMain( + duration: .fifteenMinutes, + selection: FamilyActivitySelection(), + screenTimeService: screenTime + ) + + #expect(!store.isScheduleOverrideActive(), + "A timed unblock supersedes a permanent override; its own window guard takes over") + } + @Test func startMainCancelsExistingGroupUnblocks() throws { let (service, _, store) = makeService() let screenTime = FakeScreenTimeService() @@ -246,3 +262,40 @@ struct TimedUnblockServiceTests { #expect(store.timedUnblocks.isEmpty) } } + +@MainActor +struct DeviceActivityScheduleFactoryTests { + private static let utc: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .current + return calendar + }() + + private func date(hour: Int, minute: Int, second: Int = 0) -> Date { + Self.utc.date(from: DateComponents( + year: 2026, month: 6, day: 1, hour: hour, minute: minute, second: second + ))! + } + + private func intervalSeconds(_ schedule: DeviceActivitySchedule) -> TimeInterval { + let start = Self.utc.date(from: schedule.intervalStart)! + let end = Self.utc.date(from: schedule.intervalEnd)! + return end.timeIntervalSince(start) + } + + @Test func fifteenMinuteWindowIsFlooredAboveTheMinimum() { + let start = date(hour: 10, minute: 0) + let schedule = DeviceActivityScheduleFactory.window( + from: start, to: start.addingTimeInterval(.minutes(15)), calendar: Self.utc + ) + #expect(intervalSeconds(schedule) > .minutes(15)) + } + + @Test func longerWindowIsPreservedExactly() { + let start = date(hour: 10, minute: 0) + let schedule = DeviceActivityScheduleFactory.window( + from: start, to: start.addingTimeInterval(.hours(1)), calendar: Self.utc + ) + #expect(intervalSeconds(schedule) == .hours(1)) + } +} diff --git a/NormalTests/Shared/SharedStoreTests.swift b/NormalTests/Shared/SharedStoreTests.swift index 534716a..968fe80 100644 --- a/NormalTests/Shared/SharedStoreTests.swift +++ b/NormalTests/Shared/SharedStoreTests.swift @@ -90,4 +90,51 @@ struct SharedStoreTests { #expect(loaded.count == 1) #expect(loaded.first?.name == "Test") } + + @Test func scheduleOverrideFlagRoundTrips() { + let (store, _) = makeStore() + #expect(!store.isScheduleOverrideActive(), "Defaults to off") + + store.setScheduleOverrideActive(true) + #expect(store.isScheduleOverrideActive()) + + store.setScheduleOverrideActive(false) + #expect(!store.isScheduleOverrideActive()) + } + + @Test func unblockAllInEffectReflectsTimedOrOverride() { + let (store, _) = makeStore() + #expect(!store.isUnblockAllInEffect()) + + store.setScheduleOverrideActive(true) + #expect(store.isUnblockAllInEffect(), "Permanent override counts") + + store.setScheduleOverrideActive(false) + store.upsertTimedUnblock(makeDTO(id: "main")) + #expect(store.isUnblockAllInEffect(), "Live timed unblock counts") + } + + @Test func resolveScheduleStartAppliesWhenNothingActive() { + let (store, _) = makeStore() + #expect(store.resolveScheduleStart() == .apply) + } + + @Test func resolveScheduleStartConsumesPermanentOverrideThenApplies() { + let (store, _) = makeStore() + store.setScheduleOverrideActive(true) + + #expect(store.resolveScheduleStart() == .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") + } + + @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.isScheduleOverrideActive(), + "A timed-unblock skip must not consume the permanent override") + } } diff --git a/NormalTests/TestSupport/FakeSharedStore.swift b/NormalTests/TestSupport/FakeSharedStore.swift index e69a2df..635c218 100644 --- a/NormalTests/TestSupport/FakeSharedStore.swift +++ b/NormalTests/TestSupport/FakeSharedStore.swift @@ -4,6 +4,7 @@ import Foundation final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { var timedUnblocks: [TimedUnblockDTO] = [] var schedules: [ScheduleDTO] = [] + var scheduleOverrideActive = false func loadTimedUnblocks() -> [TimedUnblockDTO] { timedUnblocks } func saveTimedUnblocks(_ unblocks: [TimedUnblockDTO]) { timedUnblocks = unblocks } @@ -28,4 +29,7 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { func saveSchedules(_ dtos: [ScheduleDTO]) { schedules = dtos } func loadSchedules() -> [ScheduleDTO] { schedules } + + func isScheduleOverrideActive() -> Bool { scheduleOverrideActive } + func setScheduleOverrideActive(_ active: Bool) { scheduleOverrideActive = active } } diff --git a/NormalUITests/RecoveryUITests.swift b/NormalUITests/RecoveryUITests.swift index 17e1a2b..b430a80 100644 --- a/NormalUITests/RecoveryUITests.swift +++ b/NormalUITests/RecoveryUITests.swift @@ -93,6 +93,27 @@ final class RecoveryUITests: XCTestCase { ) } + func testEmergencyUnblockDisablesSchedules() { + let app = launch(["-uiTestSeedSchedule"]) + + app.tabBars.buttons["Schedules"].tap() + let toggle = app.switches["schedule.enabledToggle"] + require(toggle, "Seeded schedule toggle should exist") + XCTAssertEqual(toggle.value as? String, "1", "Seeded schedule starts enabled") + + openEmergencyTab(app) + app.buttons["emergency.unblockButton"].tap() + app.alerts.buttons["Unblock All Apps"].tap() + app.alerts.buttons["OK"].tap() + app.buttons["Close"].tap() + + app.tabBars.buttons["Schedules"].tap() + XCTAssertEqual( + app.switches["schedule.enabledToggle"].value as? String, "0", + "Emergency unblock must toggle schedules off so they can't re-block" + ) + } + func testLegacyKeyWithoutScanKindShowsQRCodeLabel() { let app = launch([]) diff --git a/Shared/SharedConstants.swift b/Shared/SharedConstants.swift index 17ad70d..4e200c0 100644 --- a/Shared/SharedConstants.swift +++ b/Shared/SharedConstants.swift @@ -6,6 +6,7 @@ enum SharedConstants { enum DefaultsKey { static let timedUnblocks = "timedUnblocks_v1" static let schedules = "schedules_v1" + static let scheduleOverride = "scheduleOverride_v1" } static let mainTimedUnblockActivityName = "timedUnblock_main" diff --git a/Shared/SharedStore.swift b/Shared/SharedStore.swift index 346667b..dd0557c 100644 --- a/Shared/SharedStore.swift +++ b/Shared/SharedStore.swift @@ -56,4 +56,12 @@ struct SharedStore: SharedStoreProviding, Sendable { } return (try? PropertyListDecoder().decode([ScheduleDTO].self, from: data)) ?? [] } + + func isScheduleOverrideActive() -> Bool { + defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride) + } + + func setScheduleOverrideActive(_ active: Bool) { + defaults.set(active, forKey: SharedConstants.DefaultsKey.scheduleOverride) + } } diff --git a/Shared/SharedStoreProviding.swift b/Shared/SharedStoreProviding.swift index e325e1c..8427970 100644 --- a/Shared/SharedStoreProviding.swift +++ b/Shared/SharedStoreProviding.swift @@ -9,4 +9,23 @@ nonisolated protocol SharedStoreProviding: Sendable { func isMainTimedUnblockActive() -> Bool func saveSchedules(_ dtos: [ScheduleDTO]) func loadSchedules() -> [ScheduleDTO] + func isScheduleOverrideActive() -> Bool + func setScheduleOverrideActive(_ active: Bool) +} + +enum ScheduleStartDecision: Equatable { + case skip + case apply +} + +extension SharedStoreProviding { + func isUnblockAllInEffect() -> Bool { + isMainTimedUnblockActive() || isScheduleOverrideActive() + } + + func resolveScheduleStart() -> ScheduleStartDecision { + if isMainTimedUnblockActive() { return .skip } + if isScheduleOverrideActive() { setScheduleOverrideActive(false) } + return .apply + } }