From 57b145a7527c9ac12782c898a0834dfb50a5b01a Mon Sep 17 00:00:00 2001 From: ykisana Date: Sat, 22 Aug 2026 21:41:20 -0600 Subject: [PATCH] feat: max daily limit --- Normal.xcodeproj/project.pbxproj | 2 + Normal/App/AppContainer.swift | 3 + Normal/App/AppServices.swift | 7 + Normal/App/ContentView.swift | 21 +- Normal/App/NormalApp.swift | 1 + Normal/Models/BlockSchedule.swift | 6 +- Normal/Models/Settings.swift | 8 + Normal/Models/UsageLimit.swift | 59 +++ Normal/Models/UsageLimitEditPolicy.swift | 94 ++++ Normal/Resources/Localizable.xcstrings | 229 ++++++++++ Normal/Services/BlockActions.swift | 14 + Normal/Services/DailyLimitShield.swift | 26 ++ .../DeviceActivityScheduleFactory.swift | 15 + .../KeychainEmergencyUnblockLedger.swift | 40 +- Normal/Services/KeychainItem.swift | 58 +++ Normal/Services/ScreenTimeProviding.swift | 1 + Normal/Services/ScreenTimeService.swift | 5 + Normal/Services/ShieldStoring.swift | 9 + Normal/Services/TimedUnblockService.swift | 16 + Normal/Services/UsageLimitLedger.swift | 46 ++ Normal/Services/UsageLimitService.swift | 203 +++++++++ Normal/Shared/Components/DesignTokens.swift | 1 + .../Components/DurationWheelPicker.swift | 66 +++ Normal/Shared/Components/MessageView.swift | 20 +- Normal/Shared/Components/StatusBadge.swift | 12 +- Normal/Shared/Components/SummaryRow.swift | 26 ++ Normal/Shared/Util/DurationFormat.swift | 23 + Normal/Shared/Util/FamilyControlsUtil.swift | 18 +- Normal/Shared/Util/TimeIntervalUtil.swift | 5 +- Normal/Views/Home/BlockStatusView.swift | 19 +- Normal/Views/Home/StatusDetailView.swift | 53 ++- .../Views/Schedules/ScheduleFormSheet.swift | 7 +- .../Views/Settings/GeneralSettingsView.swift | 1 + Normal/Views/Settings/SettingsView.swift | 11 +- .../Views/Settings/UnblockDurationsView.swift | 42 +- Normal/Views/Usage/UsageLimitEditor.swift | 272 ++++++++++++ Normal/Views/Usage/UsageLimitRow.swift | 36 ++ .../Usage/UsageLimitState+Presentation.swift | 27 ++ Normal/Views/Usage/UsageLimitsGuideView.swift | 46 ++ Normal/Views/Usage/UsageLockDeadline.swift | 23 + Normal/Views/Usage/UsageSummaryView.swift | 42 ++ Normal/Views/Usage/UsageView.swift | 171 ++++++++ NormalMonitor/NormalMonitor.swift | 46 +- .../Models/UsageLimitEditPolicyTests.swift | 287 ++++++++++++ NormalTests/Services/BlockActionsTests.swift | 96 ++++ .../Services/UsageLimitServiceTests.swift | 414 ++++++++++++++++++ NormalTests/Shared/DurationFormatTests.swift | 24 + NormalTests/Shared/SharedConstantsTests.swift | 26 ++ NormalTests/Shared/UsageDTOTests.swift | 301 +++++++++++++ .../FakeDeviceActivityCenter.swift | 5 +- .../TestSupport/FakeScreenTimeService.swift | 6 + NormalTests/TestSupport/FakeSharedStore.swift | 20 + .../TestSupport/StatefulScreenTimeSpy.swift | 6 + NormalUITests/NormalUITests.swift | 28 ++ Shared/DailyLimitEnforcement.swift | 27 ++ Shared/DataExtension.swift | 13 + Shared/SharedConstants.swift | 20 + Shared/SharedDTO.swift | 8 + Shared/SharedStore.swift | 53 +++ Shared/SharedStoreProviding.swift | 64 +++ Shared/UsageDTO.swift | 170 +++++++ 61 files changed, 3278 insertions(+), 120 deletions(-) create mode 100644 Normal/Models/UsageLimit.swift create mode 100644 Normal/Models/UsageLimitEditPolicy.swift create mode 100644 Normal/Services/DailyLimitShield.swift create mode 100644 Normal/Services/KeychainItem.swift create mode 100644 Normal/Services/UsageLimitLedger.swift create mode 100644 Normal/Services/UsageLimitService.swift create mode 100644 Normal/Shared/Components/DurationWheelPicker.swift create mode 100644 Normal/Shared/Components/SummaryRow.swift create mode 100644 Normal/Shared/Util/DurationFormat.swift create mode 100644 Normal/Views/Usage/UsageLimitEditor.swift create mode 100644 Normal/Views/Usage/UsageLimitRow.swift create mode 100644 Normal/Views/Usage/UsageLimitState+Presentation.swift create mode 100644 Normal/Views/Usage/UsageLimitsGuideView.swift create mode 100644 Normal/Views/Usage/UsageLockDeadline.swift create mode 100644 Normal/Views/Usage/UsageSummaryView.swift create mode 100644 Normal/Views/Usage/UsageView.swift create mode 100644 NormalTests/Models/UsageLimitEditPolicyTests.swift create mode 100644 NormalTests/Services/UsageLimitServiceTests.swift create mode 100644 NormalTests/Shared/DurationFormatTests.swift create mode 100644 NormalTests/Shared/UsageDTOTests.swift create mode 100644 Shared/DailyLimitEnforcement.swift create mode 100644 Shared/UsageDTO.swift diff --git a/Normal.xcodeproj/project.pbxproj b/Normal.xcodeproj/project.pbxproj index 3470716..81e0910 100644 --- a/Normal.xcodeproj/project.pbxproj +++ b/Normal.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CustomDomainFilter.swift, + DailyLimitEnforcement.swift, DataExtension.swift, DomainNormalizer.swift, ManagedSettingsUtil.swift, @@ -79,6 +80,7 @@ SharedDTO.swift, SharedStore.swift, SharedStoreProviding.swift, + UsageDTO.swift, ); target = 2F4718642F5569C90013F94D /* NormalMonitor */; }; diff --git a/Normal/App/AppContainer.swift b/Normal/App/AppContainer.swift index ee8d70d..d8c261f 100644 --- a/Normal/App/AppContainer.swift +++ b/Normal/App/AppContainer.swift @@ -14,6 +14,7 @@ struct AppContainer: View { @State private var appReviewService: AppReviewService @State private var emergencyUnblockService: EmergencyUnblockService @State private var donationService = DonationService() + @State private var usageLimitService: UsageLimitService init(services: AppServices) { _screenTimeService = State(initialValue: services.screenTime) @@ -21,6 +22,7 @@ struct AppContainer: View { _scheduleService = State(initialValue: services.schedule) _appReviewService = State(initialValue: services.appReview) _emergencyUnblockService = State(initialValue: services.emergencyUnblock) + _usageLimitService = State(initialValue: services.usageLimit) } var body: some View { @@ -36,6 +38,7 @@ struct AppContainer: View { .environment(appReviewService) .environment(emergencyUnblockService) .environment(donationService) + .environment(usageLimitService) .task { mirrorCustomDomainsEnabled() } .onChange(of: allSettings.first?.enableCustomDomains ?? false) { _, enabled in scheduleService.mirrorCustomDomainsEnabled(enabled) diff --git a/Normal/App/AppServices.swift b/Normal/App/AppServices.swift index f6fbc2b..3128176 100644 --- a/Normal/App/AppServices.swift +++ b/Normal/App/AppServices.swift @@ -7,6 +7,7 @@ final class AppServices { let schedule: ScheduleService let appReview: AppReviewService let emergencyUnblock: EmergencyUnblockService + let usageLimit: UsageLimitService init() { let screenTime = ScreenTimeService() @@ -27,11 +28,17 @@ final class AppServices { defaults: UserDefaults(suiteName: "uitest-review-\(UUID().uuidString)")! ) emergencyUnblock = EmergencyUnblockService(ledger: InMemoryEmergencyUnblockLedger()) + usageLimit = UsageLimitService( + activityCenter: center, + sharedStore: store, + ledger: InMemoryUsageLimitLedger() + ) } else { timedUnblock = TimedUnblockService(onExpiration: { screenTime.notifyUpdate() }) schedule = ScheduleService() appReview = AppReviewService() emergencyUnblock = EmergencyUnblockService(ledger: KeychainEmergencyUnblockLedger()) + usageLimit = UsageLimitService() } } } diff --git a/Normal/App/ContentView.swift b/Normal/App/ContentView.swift index df661c2..1570388 100644 --- a/Normal/App/ContentView.swift +++ b/Normal/App/ContentView.swift @@ -7,9 +7,11 @@ struct ContentView: View { @Environment(TimedUnblockService.self) private var timedUnblockService @Environment(ScheduleService.self) private var scheduleService @Environment(EmergencyUnblockService.self) private var emergencyUnblockService + @Environment(UsageLimitService.self) private var usageLimitService @Environment(\.scenePhase) private var scenePhase @Query private var allSettings: [Settings] @Query private var schedules: [BlockSchedule] + @Query private var usageLimits: [UsageLimit] @State private var selectedTab: AppTab = .home @State private var navigationCoordinator = NavigationCoordinator() @@ -33,12 +35,16 @@ struct ContentView: View { if let newTab { selectedTab = newTab } } .onChange(of: onboardingService.isOnboardingActive, onOnboardingCompleted) + .onChange(of: usageLimits.count) { _, _ in reregisterUsageLimits() } + .onChange(of: settings?.usageLimitConfig) { _, _ in reregisterUsageLimits() } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { Task { await screenTimeService.checkAuthorizationStatus() } screenTimeService.notifyUpdate() timedUnblockService.refresh(screenTimeService: screenTimeService) - reregisterSchedules() + // The monitor extension may have spent a limit while we were away. + usageLimitService.notifyUpdate() + reregisterActivities() } } .onAppear(perform: onAppear) @@ -59,14 +65,23 @@ struct ContentView: View { await screenTimeService.checkAuthorizationStatus() if settings?.hasCompletedOnboarding == true { _ = await screenTimeService.ensureAuthorized() - reregisterSchedules() + reregisterActivities() } } } - private func reregisterSchedules() { + /// Re-registers every `DeviceActivity` we depend on. Usage limits ride along + /// with schedules: their threshold events use `includesPastActivity`, so + /// re-registering mid-day cannot hand back an allowance already spent. + private func reregisterActivities() { guard settings?.hasCompletedOnboarding == true else { return } scheduleService.registerAll(schedules, screenTimeService: screenTimeService) + reregisterUsageLimits() + } + + private func reregisterUsageLimits() { + guard let settings, settings.hasCompletedOnboarding else { return } + usageLimitService.registerAll(usageLimits, config: settings.usageLimitConfig) } private func onOnboardingCompleted(_: Bool, _ isActive: Bool) { diff --git a/Normal/App/NormalApp.swift b/Normal/App/NormalApp.swift index a88ddc6..f196d70 100644 --- a/Normal/App/NormalApp.swift +++ b/Normal/App/NormalApp.swift @@ -15,6 +15,7 @@ struct NormalApp: App { Key.self, BlockSchedule.self, Settings.self, + UsageLimit.self, ]) let configuration = ModelConfiguration( schema: schema, diff --git a/Normal/Models/BlockSchedule.swift b/Normal/Models/BlockSchedule.swift index 6f9a745..7f33cca 100644 --- a/Normal/Models/BlockSchedule.swift +++ b/Normal/Models/BlockSchedule.swift @@ -40,11 +40,7 @@ final class BlockSchedule: Identifiable { } var formattedDuration: String { - let hours = durationMinutes / 60 - let minutes = durationMinutes % 60 - if hours == 0 { return "\(minutes)m" } - if minutes == 0 { return "\(hours)h" } - return "\(hours)h \(minutes)m" + DurationFormat.compact(minutes: durationMinutes) } var weekdayLabels: [String] { diff --git a/Normal/Models/Settings.swift b/Normal/Models/Settings.swift index 23708d6..2c25fab 100644 --- a/Normal/Models/Settings.swift +++ b/Normal/Models/Settings.swift @@ -17,6 +17,7 @@ final class Settings { var showTimedUnblockLiveActivity: Bool = false var enableCustomDomains: Bool = false var skipBlockWithoutKeyConfirmation: Bool = false + var dailyLimitResetMinutes: Int = 0 static let maxEmergencyUnblocks = 3 private static let emergencyWindowDays = 180 @@ -38,6 +39,13 @@ final class Settings { func recordEmergencyUnblock() { emergencyUnblockDates.append(.now) } + + var usageLimitConfig: UsageLimitConfig { + UsageLimitConfig( + resetMinutes: dailyLimitResetMinutes, + preventsAppDelete: blockAllPreventsAppDelete + ) + } } extension Array where Element == Settings { diff --git a/Normal/Models/UsageLimit.swift b/Normal/Models/UsageLimit.swift new file mode 100644 index 0000000..df81386 --- /dev/null +++ b/Normal/Models/UsageLimit.swift @@ -0,0 +1,59 @@ +import FamilyControls +import Foundation +import SwiftData + +/// A daily ceiling on real usage of a chosen set of apps. +/// +/// The apps in one limit share its allowance, the way Screen Time's own App +/// Limits work. When it runs out they are shielded again for the rest of the +/// day, whatever else is unblocked. +@Model +final class UsageLimit: Identifiable { + @Attribute(.unique) var id: UUID + + var selection: FamilyActivitySelection + var minutesPerDay: Int + var sortIndex: Int = 0 + var lockAnchor: Date? + + /// Shortest allowance we offer. `DeviceActivity` thresholds below a few + /// minutes fire unreliably, so the picker never goes lower. + static let minimumMinutes = 5 + static let maximumMinutes = 23 * 60 + 55 + + init( + selection: FamilyActivitySelection = FamilyActivitySelection(), + minutesPerDay: Int, + sortIndex: Int = 0, + createdAt: Date = .now + ) { + id = UUID() + self.selection = selection + self.minutesPerDay = minutesPerDay + self.sortIndex = sortIndex + lockAnchor = createdAt + } + + func lockState(now: Date = .now) -> UsageLimitLockState { + UsageLimitEditPolicy.lockState(anchor: lockAnchor, now: now) + } + + func decide(_ edit: UsageLimitEdit, now: Date = .now) -> UsageLimitEditDecision { + UsageLimitEditPolicy.decide(edit, anchor: lockAnchor, now: now) + } + + func commit(_ allowance: UsageLimitEditAllowance, now: Date = .now) { + lockAnchor = UsageLimitEditPolicy.anchor(after: allowance, current: lockAnchor, now: now) + } + + var lockRefreshDates: [Date] { + UsageLimitEditPolicy.refreshDates(anchor: lockAnchor) + } + + /// Flattens for the monitor extension, which must be able to re-shield + /// without the app running. + func toDTO() -> UsageLimitDTO? { + guard let data = try? selection.toData() else { return nil } + return UsageLimitDTO(id: id, selectionData: data, minutesPerDay: minutesPerDay) + } +} diff --git a/Normal/Models/UsageLimitEditPolicy.swift b/Normal/Models/UsageLimitEditPolicy.swift new file mode 100644 index 0000000..c3a575f --- /dev/null +++ b/Normal/Models/UsageLimitEditPolicy.swift @@ -0,0 +1,94 @@ +import Foundation + +/// A proposed change to the usage limits, classified by direction. +/// +/// Only *loosening* changes are rate limited — tightening is always immediate, +/// so the commitment device never stands between you and more discipline. +nonisolated enum UsageLimitEdit: Equatable, Sendable { + case create(minutes: Int) + /// `dropsCoverage` is true when apps were removed from what the limit + /// meters — less watched is less restrictive, so it counts as loosening + /// even if the allowance itself shrank. + case adjust(from: Int, to: Int, dropsCoverage: Bool) + case delete(minutes: Int) + case changeReset + + var isLoosening: Bool { + switch self { + case .create: false + case let .adjust(from, to, dropsCoverage): to > from || dropsCoverage + case .delete, .changeReset: true + } + } +} + +/// Why a change was let through. Only `cooldownElapsed` starts a new week. +nonisolated enum UsageLimitEditAllowance: Equatable, Sendable { + case tightening + case grace + case cooldownElapsed +} + +nonisolated enum UsageLimitEditDecision: Equatable, Sendable { + case allowed(UsageLimitEditAllowance) + case locked(until: Date) + + var isAllowed: Bool { + if case .allowed = self { return true } + return false + } +} + +/// What the Usage screen shows before any change is proposed. +nonisolated enum UsageLimitLockState: Equatable, Sendable { + case unlocked + case grace(until: Date) + case locked(until: Date) + + var lockedUntil: Date? { + if case let .locked(until) = self { return until } + return nil + } +} + +nonisolated enum UsageLimitEditPolicy { + static let graceInterval: TimeInterval = .minutes(10) + static let cooldownInterval: TimeInterval = .days(7) + static let countdownLead: TimeInterval = .minutes(10) + + static func refreshDates(anchor: Date?) -> [Date] { + guard let anchor else { return [] } + let unlock = anchor + cooldownInterval + return [anchor + graceInterval, unlock - countdownLead, unlock] + } + + static func lockState(anchor: Date?, now: Date = .now) -> UsageLimitLockState { + guard let anchor else { return .unlocked } + let graceEnds = anchor + graceInterval + if now < graceEnds { return .grace(until: graceEnds) } + let unlock = anchor + cooldownInterval + return now < unlock ? .locked(until: unlock) : .unlocked + } + + static func decide( + _ edit: UsageLimitEdit, + anchor: Date?, + now: Date = .now + ) -> UsageLimitEditDecision { + guard edit.isLoosening else { return .allowed(.tightening) } + switch lockState(anchor: anchor, now: now) { + case .unlocked: return .allowed(.cooldownElapsed) + case .grace: return .allowed(.grace) + case let .locked(until): return .locked(until: until) + } + } + + /// The anchor to persist after a change was applied. + static func anchor( + after allowance: UsageLimitEditAllowance, + current: Date?, + now: Date = .now + ) -> Date? { + allowance == .cooldownElapsed ? now : current + } +} diff --git a/Normal/Resources/Localizable.xcstrings b/Normal/Resources/Localizable.xcstrings index 3d8d456..8f43f52 100644 --- a/Normal/Resources/Localizable.xcstrings +++ b/Normal/Resources/Localizable.xcstrings @@ -24,6 +24,18 @@ } } }, + "%@ · %@ a day" : { + "comment" : "A row of text that shows the number of selected apps and the limit for the day.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@ a day" + } + } + } + }, "%@ Copy" : { "comment" : "A suggested name for a copy of a file. The argument is the original file name.", "isCommentAutoGenerated" : true @@ -48,6 +60,10 @@ "comment" : "A confirmation alert that lets the user delete a timed unblock duration. The argument is the name of the duration.", "isCommentAutoGenerated" : true }, + "%@ left to adjust" : { + "comment" : "A countdown to when a usage limit can be adjusted. The argument is the number of minutes remaining.", + "isCommentAutoGenerated" : true + }, "%@ will be permanently removed." : { "comment" : "A message shown when a user confirms the deletion of a key. The argument is the name of the key that is being deleted.", "isCommentAutoGenerated" : true @@ -80,6 +96,29 @@ "comment" : "A subheading displaying the number of items currently selected in the app.", "isCommentAutoGenerated" : true }, + "%lld Max Daily Limits" : { + "comment" : "Max Daily Limits row in Settings — title. The argument is the number of Max Daily Limits.", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Max Daily Limit" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Max Daily Limits" + } + } + } + } + } + } + }, "%lld of %lld blocked" : { "comment" : "A detail text showing how many apps are currently blocked out of the total selection.", "isCommentAutoGenerated" : true, @@ -104,6 +143,17 @@ } } }, + "%lld of %lld Reached" : { + "comment" : "Max Daily Limits row in Settings — title when some limits ran out. The first argument is how many ran out, the second how many limits exist.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld of %2$lld Reached" + } + } + } + }, "%lld of %lld remaining" : { "comment" : "A text display showing the number of emergency unblocks remaining and a button to trigger one.", "isCommentAutoGenerated" : true, @@ -138,6 +188,10 @@ "comment" : "Plural form of \"1 Category\".", "isCommentAutoGenerated" : true }, + "1 hour a day for games" : { + "comment" : "A suggested usage limit for gaming.", + "isCommentAutoGenerated" : true + }, "1 Website" : { "comment" : "Plural form of \"Website\".", "isCommentAutoGenerated" : true @@ -146,6 +200,14 @@ "comment" : "A step number in the \"Steps\" section of the Settings > Bypass guide.", "isCommentAutoGenerated" : true }, + "15 minutes a day for news or shopping websites" : { + "comment" : "A usage limit for news or shopping websites.", + "isCommentAutoGenerated" : true + }, + "30 minutes a day across all your social apps" : { + "comment" : "A usage limit suggestion for social media apps.", + "isCommentAutoGenerated" : true + }, "Absolutely, Normal was designed for exactly this." : { }, @@ -204,6 +266,10 @@ "comment" : "Description of the onboarding step for the keys tab.", "isCommentAutoGenerated" : true }, + "Add Limit" : { + "comment" : "A button that adds a new app usage limit.", + "isCommentAutoGenerated" : true + }, "Add Schedule" : { "comment" : "A button label that opens a sheet to add a new schedule.", "isCommentAutoGenerated" : true @@ -214,6 +280,20 @@ "Adds a section to App Select, allowing you to type custom website domains to block." : { "comment" : "A footer description for the custom domains section in the general settings view.", "isCommentAutoGenerated" : true + }, + "After %@ of combined use, these stay blocked until %@, even while everything else is unblocked. Time already used today counts; if you're already past it, the limit starts at the next reset." : { + "comment" : "Explains a Max Daily Limit. The first argument is the allowance, e.g. “1 hour”; the second is the reset time.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "After %1$@ of combined use, these stay blocked until %2$@, even while everything else is unblocked. Time already used today counts; if you're already past it, the limit starts at the next reset." + } + } + } + }, + "After saving, you have 10 minutes to adjust this limit. Then raising or deleting it is allowed once every 7 days." : { + }, "All app blocks have been removed. You have %lld emergency unblock%@ remaining." : { "comment" : "A message displayed after successfully unblocking all apps.", @@ -242,6 +322,12 @@ "Almost any NFC tag works. AirTags, Amiibo, and even Credit Cards have NFC chips you can use." : { "comment" : "Feature description for using an NFC tag as a key.", "isCommentAutoGenerated" : true + }, + "Almost Reached" : { + "comment" : "State of a Max Daily Limit whose time is nearly used up." + }, + "Already in another limit. Remove them here, or change that limit instead." : { + }, "Another room, a closet, or a high shelf" : { @@ -306,6 +392,10 @@ "comment" : "A message displayed when the user tries to delete the last unblock duration.", "isCommentAutoGenerated" : true }, + "Available" : { + "comment" : "Short label for a usage limit state when the limit is not yet reached.", + "isCommentAutoGenerated" : true + }, "Block" : { "comment" : "A button to block access to a family member's account.", "isCommentAutoGenerated" : true @@ -388,14 +478,25 @@ "comment" : "A button that dismisses the current view without saving any changes.", "isCommentAutoGenerated" : true }, + "Cap how long you can use apps each day. Once a limit runs out, those apps stay blocked until the day is over, even if you unblock with a key." : { + "comment" : "A description of how usage limits work.", + "isCommentAutoGenerated" : true + }, "Categories" : { "comment" : "A section header for categories in the app picker.", "isCommentAutoGenerated" : true }, + "Choose how much time they get each day. Time you've already used today counts." : { + "comment" : "How it works section of the usage limits guide.", + "isCommentAutoGenerated" : true + }, "Choose Key" : { "comment" : "The title of the view that lets users choose a key to unlock a door.", "isCommentAutoGenerated" : true }, + "Choose the apps, websites, or categories this limit measures." : { + "comment" : "Footer shown before any apps are picked for a Max Daily Limit." + }, "Choose which apps you want Normal to manage. These are the apps that can be blocked." : { "comment" : "Description of the onboarding step for the app select tab.", "isCommentAutoGenerated" : true @@ -491,6 +592,12 @@ }, "Delete Key?" : { + }, + "Delete Limit" : { + "comment" : "Button that deletes a Max Daily Limit." + }, + "Delete Limit?" : { + "comment" : "Title of the confirmation before deleting a Max Daily Limit." }, "Delete Schedule" : { "comment" : "A button label that deletes a schedule.", @@ -500,6 +607,10 @@ "comment" : "A confirmation alert title that asks if the user wants to delete a schedule.", "isCommentAutoGenerated" : true }, + "Deleting a limit loosens it. You can delete it %@." : { + "comment" : "A warning that a deleted limit will be automatically removed from the user's usage limits.", + "isCommentAutoGenerated" : true + }, "Disable" : { "comment" : "The text on a button that disables a schedule.", "isCommentAutoGenerated" : true @@ -559,6 +670,12 @@ "comment" : "The title of the navigation bar for editing an existing app group.", "isCommentAutoGenerated" : true }, + "Edit limit" : { + "comment" : "Accessibility hint on a Max Daily Limit row." + }, + "Edit Limit" : { + "comment" : "Title of the editor for an existing Max Daily Limit." + }, "Edit Schedule" : { "comment" : "The title of the navigation bar for editing a schedule.", "isCommentAutoGenerated" : true @@ -598,6 +715,14 @@ "comment" : "A label for the end time of a schedule.", "isCommentAutoGenerated" : true }, + "Every limit resets at the time you set. Once you have limits, it can be changed once every 7 days. Unused time doesn't carry over." : { + "comment" : "Text describing how limits reset and can be changed.", + "isCommentAutoGenerated" : true + }, + "Every limit's time resets at this time each day. While you have limits, it can be changed once every 7 days, with 10 minutes to adjust after each change." : { + "comment" : "A description of the usage reset time.", + "isCommentAutoGenerated" : true + }, "Example" : { }, @@ -664,6 +789,10 @@ "comment" : "A bullet point in the \"Pick one option for the passcode and Apple ID\" section of the SettingsBypassGuide view.", "isCommentAutoGenerated" : true }, + "Hard cap on daily app use" : { + "comment" : "A description of the max daily limit usage.", + "isCommentAutoGenerated" : true + }, "Head to our GitHub repository to get started, check out open issues, or submit a pull request." : { }, @@ -688,6 +817,10 @@ }, "How it works" : { + }, + "How It Works" : { + "comment" : "A section that describes how usage limits work.", + "isCommentAutoGenerated" : true }, "How it works:" : { "comment" : "A heading within the \"How Emergency Unblocks Work\" section of the Emergency Unblock view.", @@ -704,6 +837,9 @@ "hr" : { "comment" : "A unit of time.", "isCommentAutoGenerated" : true + }, + "Ideas" : { + }, "If you have any issues, feedback or praise, please don't hesitate to contact us." : { "comment" : "A description of the purpose of the contact form.", @@ -714,6 +850,10 @@ }, "Important notes" : { + }, + "in %@" : { + "comment" : "A countdown timer. The argument is the countdown.", + "isCommentAutoGenerated" : true }, "info@normalengineering.org" : { @@ -761,6 +901,13 @@ "comment" : "An alert message displayed when a user tries to change the key type of an existing key.", "isCommentAutoGenerated" : true }, + "Limit Reached" : { + "comment" : "State of a Max Daily Limit whose time is used up." + }, + "Limits must be at least %lld minutes." : { + "comment" : "A warning that limits cannot be set below a certain number of minutes.", + "isCommentAutoGenerated" : true + }, "Live Activity" : { "comment" : "A toggle that enables or disables the live activity feature in the unblocking process.", "isCommentAutoGenerated" : true @@ -797,6 +944,9 @@ "comment" : "A description of the second method of bypassing the Screen Time restriction.", "isCommentAutoGenerated" : true }, + "Max Daily Limits" : { + "comment" : "Title of the Max Daily Limits screen and its row in Settings." + }, "Method 1: Shortcuts automation" : { "comment" : "Title of a section in the Settings bypass guide that explains Method 1.", "isCommentAutoGenerated" : true @@ -841,6 +991,10 @@ "comment" : "The title of a sheet used to create a new family app group.", "isCommentAutoGenerated" : true }, + "New Limit" : { + "comment" : "A title for a new usage limit.", + "isCommentAutoGenerated" : true + }, "New Schedule" : { "comment" : "The title of the navigation bar for creating a new schedule.", "isCommentAutoGenerated" : true @@ -892,6 +1046,9 @@ "comment" : "A title displayed in the header of the \"No Keys Yet\" view.", "isCommentAutoGenerated" : true }, + "No Max Daily Limits" : { + "comment" : "Shown when no Max Daily Limits exist." + }, "No Schedules" : { "comment" : "A label displayed when the user has not created any schedules.", "isCommentAutoGenerated" : true @@ -966,10 +1123,17 @@ "comment" : "A string describing a state where a schedule is enabled.", "isCommentAutoGenerated" : true }, + "on %@" : { + "comment" : "A date and time, in the future, in the format \"on Oct 8, 2026 at 2:07:45 AM\".", + "isCommentAutoGenerated" : true + }, "once" : { "comment" : "A label that appears below a donation amount, indicating that it's a one-time gift.", "isCommentAutoGenerated" : true }, + "Once a limit runs out, its apps stay blocked until the reset, even while everything else is unblocked." : { + "comment" : "Footer under the list of Max Daily Limits." + }, "One-time tip" : { "comment" : "A label displayed as the footer of a donation view section that offers one-time tips.", "isCommentAutoGenerated" : true @@ -1002,6 +1166,16 @@ "comment" : "Description of a onboarding step for the groups tab.", "isCommentAutoGenerated" : true }, + "Paused" : { + "comment" : "Label for a usage limit that is paused.", + "isCommentAutoGenerated" : true + }, + "Paused by emergency unblock until %@." : { + "comment" : "Notice on the Max Daily Limits screen after an emergency unblock. The argument is the reset time." + }, + "Paused until %@" : { + "comment" : "Max Daily Limits row in Settings — subtitle after an emergency unblock. The argument is the reset time." + }, "per month" : { "comment" : "A unit of measurement for a monthly donation.", "isCommentAutoGenerated" : true @@ -1010,6 +1184,10 @@ "comment" : "A label indicating that a schedule is permanent.", "isCommentAutoGenerated" : true }, + "Pick apps, websites, or categories. You can group multiple apps in a single limit." : { + "comment" : "A row in the \"How It Works\" section of the usage limits guide, showing how usage limits work.", + "isCommentAutoGenerated" : true + }, "Pick on Map" : { "comment" : "A label for a button that allows the user to pick a location on a map.", "isCommentAutoGenerated" : true @@ -1074,6 +1252,20 @@ "comment" : "A section header that indicates that a schedule can repeat on certain weekdays.", "isCommentAutoGenerated" : true }, + "Reset Time" : { + "comment" : "Title of the sheet that changes when Max Daily Limits reset." + }, + "Resets At" : { + "comment" : "Label for the time Max Daily Limits reset each day." + }, + "Resets at %@" : { + "comment" : "A description of the next reset time for the usage limits.", + "isCommentAutoGenerated" : true + }, + "Resets at %@." : { + "comment" : "A footer that shows when the limit is paused, or when it resets.", + "isCommentAutoGenerated" : true + }, "Save" : { "comment" : "The text of a button that saves a newly created app group.", "isCommentAutoGenerated" : true @@ -1257,6 +1449,9 @@ }, "There are no accounts, no internet connection required, and no data logging." : { + }, + "These apps, websites, and categories share one allowance." : { + "comment" : "Footer under the apps of a Max Daily Limit." }, "This device can't scan NFC, so NFC tags won't work here. Use a QR code, barcode, or location instead." : { "comment" : "A description of how to use a QR code, barcode, or location as a key.", @@ -1272,14 +1467,24 @@ }, "This is an Apple limitation, not a Normal one." : { + }, + "This limit" : { + "comment" : "Names the limit in the delete confirmation: “This limit will be permanently removed.”" }, "This will immediately remove all app blocks. Each unblock regenerates after 6 months. You have %lld remaining." : { "comment" : "An explanatory text that appears when the user confirms an emergency unblock.", "isCommentAutoGenerated" : true }, + "Tighten anytime: lowering a limit or adding apps always works. After creating a limit raising or deleting it is allowed once every 7 days." : { + "comment" : "Text describing how to change limits anytime.", + "isCommentAutoGenerated" : true + }, "Time" : { "comment" : "A section header that says \"Time\".", "isCommentAutoGenerated" : true + }, + "Time Per Day" : { + }, "Timed" : { "comment" : "A label describing a timed schedule.", @@ -1377,6 +1582,10 @@ "comment" : "A label for a section in the timed unblock sheet that indicates the option to unblock indefinitely.", "isCommentAutoGenerated" : true }, + "Unused time does not carry over." : { + "comment" : "A description of how the reset time is calculated.", + "isCommentAutoGenerated" : true + }, "Update" : { "comment" : "The label of a button that updates an existing key.", "isCommentAutoGenerated" : true @@ -1445,6 +1654,10 @@ "comment" : "A footer label explaining the purpose of the toggle.", "isCommentAutoGenerated" : true }, + "When the time runs out, they stay blocked until the reset. Only Emergency Unblock lifts them early." : { + "comment" : "Text describing the behavior of blocked apps after their time limit is up.", + "isCommentAutoGenerated" : true + }, "Where to place them" : { "comment" : "A section title describing where to place the keys.", "isCommentAutoGenerated" : true @@ -1475,6 +1688,22 @@ }, "You can also reach us at" : { + }, + "You can change the reset time again %@." : { + "comment" : "A message that lets the user know they can change the reset time again. The argument is a phrase that describes the deadline for changing the reset time.", + "isCommentAutoGenerated" : true + }, + "You can change the reset time for another %@." : { + "comment" : "A countdown to the next reset time.", + "isCommentAutoGenerated" : true + }, + "You can lower this limit or add apps anytime. Raising it, removing apps, or deleting it is available again %@." : { + "comment" : "A warning that a usage limit is locked, so it can't be changed. The argument is the date the limit can be changed again.", + "isCommentAutoGenerated" : true + }, + "You can raise, delete, or remove apps from this limit for another %@." : { + "comment" : "A countdown to a deadline.", + "isCommentAutoGenerated" : true }, "You can save up to %lld durations." : { "comment" : "A footer that explains that the user can only add a certain number of unblock durations.", diff --git a/Normal/Services/BlockActions.swift b/Normal/Services/BlockActions.swift index 42b4680..4812e4e 100644 --- a/Normal/Services/BlockActions.swift +++ b/Normal/Services/BlockActions.swift @@ -36,6 +36,20 @@ enum BlockActions { } } + static func emergencyUnblock( + schedules: [BlockSchedule], + screenTimeService: any ScreenTimeProviding, + timedUnblockService: TimedUnblockService, + scheduleService: ScheduleService, + usageLimitService: UsageLimitService + ) { + timedUnblockService.discardAll() + scheduleService.disableAll(schedules, screenTimeService: screenTimeService) + scheduleService.setScheduleOverride(false) + usageLimitService.overrideToday() + screenTimeService.removeAllRestrictions() + } + static func validate(isAuthorized: Bool, hasCompletedOnboarding: Bool, keys: [Key]) throws { guard hasCompletedOnboarding else { throw BlockIntentError.setupIncomplete } guard isAuthorized else { throw BlockIntentError.screenTimeNotAuthorized } diff --git a/Normal/Services/DailyLimitShield.swift b/Normal/Services/DailyLimitShield.swift new file mode 100644 index 0000000..61e3f94 --- /dev/null +++ b/Normal/Services/DailyLimitShield.swift @@ -0,0 +1,26 @@ +import Foundation +import ManagedSettings + +protocol DailyLimitShielding: AnyObject { + func apply(_ reached: [UsageLimitDTO], preventsAppDelete: Bool) +} + +final class ManagedSettingsDailyLimitShield: DailyLimitShielding { + private let store = ManagedSettingsStore(named: .dailyLimits) + + func apply(_ reached: [UsageLimitDTO], preventsAppDelete: Bool) { + store.applyDailyLimits(reached, preventsAppDelete: preventsAppDelete) + } +} + +final class InMemoryDailyLimitShield: DailyLimitShielding { + private(set) var shieldedLimitIDs: [UUID] = [] + private(set) var preventsAppDelete = false + private(set) var applyCount = 0 + + func apply(_ reached: [UsageLimitDTO], preventsAppDelete: Bool) { + shieldedLimitIDs = reached.map(\.id) + self.preventsAppDelete = preventsAppDelete && !reached.isEmpty + applyCount += 1 + } +} diff --git a/Normal/Services/DeviceActivityScheduleFactory.swift b/Normal/Services/DeviceActivityScheduleFactory.swift index 5db33b5..f7ad21d 100644 --- a/Normal/Services/DeviceActivityScheduleFactory.swift +++ b/Normal/Services/DeviceActivityScheduleFactory.swift @@ -19,4 +19,19 @@ enum DeviceActivityScheduleFactory { repeats: false ) } + + static func daily(resetMinutes: Int, warningMinutes: Int) -> DeviceActivitySchedule { + let startSeconds = resetMinutes * 60 + let endSeconds = (startSeconds - 1 + 86400) % 86400 + return DeviceActivitySchedule( + intervalStart: DateComponents(hour: resetMinutes / 60, minute: resetMinutes % 60, second: 0), + intervalEnd: DateComponents( + hour: endSeconds / 3600, + minute: endSeconds % 3600 / 60, + second: endSeconds % 60 + ), + repeats: true, + warningTime: DateComponents(minute: warningMinutes) + ) + } } diff --git a/Normal/Services/KeychainEmergencyUnblockLedger.swift b/Normal/Services/KeychainEmergencyUnblockLedger.swift index 0d193d4..d8c408c 100644 --- a/Normal/Services/KeychainEmergencyUnblockLedger.swift +++ b/Normal/Services/KeychainEmergencyUnblockLedger.swift @@ -1,53 +1,21 @@ import Foundation -import Security struct KeychainEmergencyUnblockLedger: EmergencyUnblockLedger { - private let service: String - private let account: String + private let item: KeychainItem init( service: String = "com.normalengineering.normal.emergencyUnblock", account: String = "ledger" ) { - self.service = service - self.account = account + item = KeychainItem(service: service, account: account) } func load() -> [Date] { - var query = baseQuery() - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var item: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data, - let seconds = try? PropertyListDecoder().decode([Double].self, from: data) - else { return [] } - + guard let seconds = item.load([Double].self) else { return [] } return seconds.map { Date(timeIntervalSinceReferenceDate: $0) } } func save(_ dates: [Date]) { - let seconds = dates.map(\.timeIntervalSinceReferenceDate) - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - guard let data = try? encoder.encode(seconds) else { return } - - let update = [kSecValueData as String: data] - let status = SecItemUpdate(baseQuery() as CFDictionary, update as CFDictionary) - guard status == errSecItemNotFound else { return } - - var insert = baseQuery() - insert[kSecValueData as String] = data - insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - SecItemAdd(insert as CFDictionary, nil) - } - - private func baseQuery() -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] + item.save(dates.map(\.timeIntervalSinceReferenceDate)) } } diff --git a/Normal/Services/KeychainItem.swift b/Normal/Services/KeychainItem.swift new file mode 100644 index 0000000..c4ddfd5 --- /dev/null +++ b/Normal/Services/KeychainItem.swift @@ -0,0 +1,58 @@ +import Foundation +import Security + +/// A single generic-password slot in the Keychain. +/// +/// Used for counters that must survive a delete-and-reinstall — the whole point +/// of storing them outside the app container. +struct KeychainItem: Sendable { + let service: String + let account: String + + func loadData() -> Data? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return item as? Data + } + + func saveData(_ data: Data) { + let update = [kSecValueData as String: data] + let status = SecItemUpdate(baseQuery() as CFDictionary, update as CFDictionary) + guard status == errSecItemNotFound else { return } + + var insert = baseQuery() + insert[kSecValueData as String] = data + insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + SecItemAdd(insert as CFDictionary, nil) + } + + func delete() { + SecItemDelete(baseQuery() as CFDictionary) + } + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} + +extension KeychainItem { + func load(_ type: T.Type) -> T? { + guard let data = loadData() else { return nil } + return try? PropertyListDecoder().decode(type, from: data) + } + + func save(_ value: some Encodable) { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + guard let data = try? encoder.encode(value) else { return } + saveData(data) + } +} diff --git a/Normal/Services/ScreenTimeProviding.swift b/Normal/Services/ScreenTimeProviding.swift index 2a0f6bb..7516071 100644 --- a/Normal/Services/ScreenTimeProviding.swift +++ b/Normal/Services/ScreenTimeProviding.swift @@ -20,6 +20,7 @@ protocol ScreenTimeProviding: AnyObject { blockAllPreventsAppDelete: Bool ) func removeShieldOnAll(blockAllPreventsAppDelete: Bool) + func removeAllRestrictions() func addToShields(selection: FamilyActivitySelection, customDomains: [String]) func removeFromShields(selection: FamilyActivitySelection, customDomains: [String]) func clearCustomDomainFilter() diff --git a/Normal/Services/ScreenTimeService.swift b/Normal/Services/ScreenTimeService.swift index 7e10157..0d12357 100644 --- a/Normal/Services/ScreenTimeService.swift +++ b/Normal/Services/ScreenTimeService.swift @@ -99,6 +99,11 @@ final class ScreenTimeService: ScreenTimeProviding { notifyUpdate() } + func removeAllRestrictions() { + shield.clearAllSettings() + notifyUpdate() + } + func addToShields(selection: FamilyActivitySelection, customDomains: [String] = []) { shield.union(with: selection, customDomains: customDomains) notifyUpdate() diff --git a/Normal/Services/ShieldStoring.swift b/Normal/Services/ShieldStoring.swift index ce09493..14e9706 100644 --- a/Normal/Services/ShieldStoring.swift +++ b/Normal/Services/ShieldStoring.swift @@ -5,6 +5,7 @@ protocol ShieldStoring: AnyObject { var denyAppRemoval: Bool { get set } func replace(with selection: FamilyActivitySelection, customDomains: [String]) func clear() + func clearAllSettings() func union(with selection: FamilyActivitySelection, customDomains: [String]) func subtract(with selection: FamilyActivitySelection, customDomains: [String]) func clearCustomDomainFilter() @@ -32,6 +33,10 @@ final class ManagedSettingsShieldStore: ShieldStoring { store.clearFilterDomains() } + func clearAllSettings() { + store.clearAllSettings() + } + func union(with selection: FamilyActivitySelection, customDomains: [String]) { store.unionShields(with: selection) store.unionFilterDomains(customDomains) @@ -103,6 +108,10 @@ final class InMemoryShieldStore: ShieldStoring { func replace(with _: FamilyActivitySelection, customDomains _: [String]) { shielded = true } func clear() { shielded = false } + func clearAllSettings() { + shielded = false + denyAppRemoval = false + } func union(with _: FamilyActivitySelection, customDomains _: [String]) { shielded = true } func subtract(with _: FamilyActivitySelection, customDomains _: [String]) { shielded = false } func clearCustomDomainFilter() {} diff --git a/Normal/Services/TimedUnblockService.swift b/Normal/Services/TimedUnblockService.swift index a0094fd..4ea45eb 100644 --- a/Normal/Services/TimedUnblockService.swift +++ b/Normal/Services/TimedUnblockService.swift @@ -145,6 +145,22 @@ final class TimedUnblockService { } } + func discardAll() { + let activityNames = Set( + sharedStore.loadTimedUnblocks().map(\.activityName) + + activeUnblocks.keys.map { key in + UUID(uuidString: key).map(SharedConstants.groupTimedUnblockActivityName(for:)) + ?? SharedConstants.mainTimedUnblockActivityName + } + ) + sharedStore.saveTimedUnblocks([]) + for key in Array(activeUnblocks.keys) { + cancelExpiration(id: key) + } + activeUnblocks.removeAll() + activityCenter.stopMonitoring(activityNames.map { DeviceActivityName($0) }) + } + func updateMainSelection(_ selection: FamilyActivitySelection, customDomains: [String] = []) { guard let endDate = activeUnblocks[Self.mainID] else { return } let activityName = SharedConstants.mainTimedUnblockActivityName diff --git a/Normal/Services/UsageLimitLedger.swift b/Normal/Services/UsageLimitLedger.swift new file mode 100644 index 0000000..add9440 --- /dev/null +++ b/Normal/Services/UsageLimitLedger.swift @@ -0,0 +1,46 @@ +import Foundation + +nonisolated protocol UsageLimitLedger: Sendable { + func loadLastLoosened() -> Date? + func saveLastLoosened(_ date: Date?) +} + +struct KeychainUsageLimitLedger: UsageLimitLedger { + private let item: KeychainItem + + init( + service: String = "com.normalengineering.normal.usageLimits", + account: String = "lastLoosened" + ) { + item = KeychainItem(service: service, account: account) + } + + func loadLastLoosened() -> Date? { + item.load(Double.self).map(Date.init(timeIntervalSinceReferenceDate:)) + } + + func saveLastLoosened(_ date: Date?) { + guard let date else { + item.delete() + return + } + item.save(date.timeIntervalSinceReferenceDate) + } +} + +final class InMemoryUsageLimitLedger: UsageLimitLedger, @unchecked Sendable { + private let lock = NSLock() + private var date: Date? + + init(date: Date? = nil) { + self.date = date + } + + func loadLastLoosened() -> Date? { + lock.withLock { date } + } + + func saveLastLoosened(_ date: Date?) { + lock.withLock { self.date = date } + } +} diff --git a/Normal/Services/UsageLimitService.swift b/Normal/Services/UsageLimitService.swift new file mode 100644 index 0000000..79e451c --- /dev/null +++ b/Normal/Services/UsageLimitService.swift @@ -0,0 +1,203 @@ +import DeviceActivity +import FamilyControls +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class UsageLimitService { + private let activityCenter: any DeviceActivityProviding + private let sharedStore: any SharedStoreProviding + private let ledger: any UsageLimitLedger + private let limitShield: any DailyLimitShielding + + /// Bumped whenever shared-extension state may have changed, so views that + /// read through `sharedStore` re-evaluate. Mirrors `ScreenTimeService`. + private(set) var lastUpdate: Date = .now + + private(set) var resetAnchor: Date? + + /// Minutes of usage before a limit is hit that `eventWillReachThresholdWarning` fires. + static let warningMinutes = SharedConstants.usageLimitWarningMinutes + + private let logger = Logger(subsystem: "com.normalengineering.normal", category: "UsageLimits") + + init( + activityCenter: any DeviceActivityProviding = DeviceActivityCenter(), + sharedStore: any SharedStoreProviding = SharedStore(), + ledger: any UsageLimitLedger = KeychainUsageLimitLedger(), + limitShield: (any DailyLimitShielding)? = nil + ) { + self.activityCenter = activityCenter + self.sharedStore = sharedStore + self.ledger = ledger + self.limitShield = limitShield + ?? (UITestSupport.isActive ? InMemoryDailyLimitShield() : ManagedSettingsDailyLimitShield()) + resetAnchor = ledger.loadLastLoosened() + } + + func notifyUpdate() { + lastUpdate = .now + } + + // MARK: - Registration + + func registerAll(_ limits: [UsageLimit], config: UsageLimitConfig, on date: Date = .now) { + let dtos = limits.compactMap { $0.toDTO() } + if dtos.count != limits.count { + logger.error("Dropped \(limits.count - dtos.count, privacy: .public) limit(s) that failed to encode") + } + sharedStore.saveUsageLimits(dtos) + sharedStore.saveUsageLimitConfig(config) + syncShields(on: date) + + let specs = Self.eventSpecs(for: dtos).filter(\.isArmable) + let fingerprint = Self.fingerprint(for: dtos, period: config.period, on: date) + guard fingerprint != sharedStore.loadUsageRegistration() else { return } + + let activityName = DeviceActivityName(SharedConstants.usageLimitsActivityName) + activityCenter.stopMonitoring([activityName]) + sharedStore.saveUsageRegistration(nil) + + guard !specs.isEmpty else { + sharedStore.saveUsageRegistration(fingerprint) + notifyUpdate() + return + } + + do { + try activityCenter.startMonitoring( + activityName, + during: DeviceActivityScheduleFactory.daily( + resetMinutes: config.period.resetMinutes, + warningMinutes: Self.warningMinutes + ), + events: specs.reduce(into: [:]) { $0[$1.name] = $1.event } + ) + sharedStore.saveUsageRegistration(fingerprint) + } catch { + // Every limit shares one activity, so a throw here disables all of + // them. Leave the fingerprint cleared so the next launch retries. + logger.error("Usage limit monitoring failed: \(error.localizedDescription, privacy: .public)") + } + notifyUpdate() + } + + /// One threshold event per limit that still has something to watch. + /// + /// Split out from the `DeviceActivityEvent` construction so the naming and + /// filtering rules are checkable without real `ManagedSettings` tokens. + struct EventSpec: Equatable { + let id: UUID + let eventName: String + let minutes: Int + let selection: FamilyActivitySelection + + var name: DeviceActivityEvent.Name { DeviceActivityEvent.Name(eventName) } + + /// A selection with no tokens has nothing to meter. Registering it + /// would leave a limit that looks armed but can never fire. + var isArmable: Bool { !selection.isEmpty } + + var event: DeviceActivityEvent { + DeviceActivityEvent( + applications: selection.applicationTokens, + categories: selection.categoryTokens, + webDomains: selection.webDomainTokens, + threshold: DateComponents(minute: minutes), + // Accounts for usage already spent earlier in the interval, so + // re-registering mid-day cannot refund an allowance. + includesPastActivity: true + ) + } + } + + /// Drops only limits whose selection cannot be decoded; whether a spec is + /// worth arming is `EventSpec.isArmable`, kept separate so the naming and + /// threshold rules stay checkable without real `ManagedSettings` tokens. + static func eventSpecs(for dtos: [UsageLimitDTO]) -> [EventSpec] { + dtos.compactMap { dto in + guard let selection = try? FamilyActivitySelection.fromData(dto.selectionData) + else { return nil } + return EventSpec( + id: dto.id, + eventName: SharedConstants.usageLimitEventName(for: dto.id), + minutes: dto.minutesPerDay, + selection: selection + ) + } + } + + static func fingerprint(for dtos: [UsageLimitDTO], period: UsagePeriod, on date: Date) -> String { + let body = dtos + .map { "\($0.id.uuidString):\($0.minutesPerDay):\($0.selectionData.stableChecksum)" } + .sorted() + .joined(separator: "|") + return "\(period.key(for: date))@\(period.resetMinutes)#\(body)" + } + + // MARK: - Today's state + + func state(for limit: UsageLimit, on date: Date = .now) -> UsageLimitState { + _ = lastUpdate + return sharedStore.usageState(for: limit.id, on: date) + } + + func states(for limits: [UsageLimit], on date: Date = .now) -> [UUID: UsageLimitState] { + _ = lastUpdate + return sharedStore.usageStates(for: limits.map(\.id), on: date) + } + + func nextReset(after date: Date = .now) -> Date { + _ = lastUpdate + return sharedStore.usagePeriod.nextReset(after: date) + } + + func isDayOverridden(on date: Date = .now) -> Bool { + _ = lastUpdate + return sharedStore.isUsageDayOverridden(on: date) + } + + func resetLockState(now: Date = .now) -> UsageLimitLockState { + UsageLimitEditPolicy.lockState(anchor: resetAnchor, now: now) + } + + func decideResetChange(now: Date = .now) -> UsageLimitEditDecision { + UsageLimitEditPolicy.decide(.changeReset, anchor: resetAnchor, now: now) + } + + func resetLockRefreshDates() -> [Date] { + UsageLimitEditPolicy.refreshDates(anchor: resetAnchor) + } + + func commitResetChange(_ allowance: UsageLimitEditAllowance, now: Date = .now) { + let anchor = UsageLimitEditPolicy.anchor( + after: allowance, + current: resetAnchor, + now: now + ) + ledger.saveLastLoosened(anchor) + resetAnchor = anchor + notifyUpdate() + } + + func overrideToday(on date: Date = .now) { + sharedStore.overrideUsageDay(on: date) + limitShield.apply([], preventsAppDelete: false) + notifyUpdate() + } + + func clearState(for limit: UsageLimit, on date: Date = .now) { + sharedStore.clearUsageState(for: limit.id, on: date) + syncShields(on: date) + notifyUpdate() + } + + private func syncShields(on date: Date) { + limitShield.apply( + sharedStore.reachedUsageLimits(on: date), + preventsAppDelete: sharedStore.loadUsageLimitConfig().preventsAppDelete + ) + } +} diff --git a/Normal/Shared/Components/DesignTokens.swift b/Normal/Shared/Components/DesignTokens.swift index 34db843..bf196b0 100644 --- a/Normal/Shared/Components/DesignTokens.swift +++ b/Normal/Shared/Components/DesignTokens.swift @@ -29,5 +29,6 @@ enum DS { static let chipHeight: CGFloat = 34 static let avatar: CGFloat = 48 static let iconWell: CGFloat = 28 + static let summaryIcon: CGFloat = 36 } } diff --git a/Normal/Shared/Components/DurationWheelPicker.swift b/Normal/Shared/Components/DurationWheelPicker.swift new file mode 100644 index 0000000..766be77 --- /dev/null +++ b/Normal/Shared/Components/DurationWheelPicker.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct DurationWheelPicker: View { + @Binding var minutes: Int + + let maxMinutes: Int + var minuteStep = 5 + var identifierPrefix: String? + + private var hourOptions: [Int] { Array(0 ... (maxMinutes / 60)) } + private var minuteOptions: [Int] { Array(stride(from: 0, to: 60, by: minuteStep)) } + + var body: some View { + HStack(spacing: 0) { + wheel( + "Hours", + unit: "hr", + options: hourOptions, + selection: binding(get: { $0 / 60 }, set: { $1 * 60 + $0 % 60 }), + identifier: "hours" + ) + wheel( + "Minutes", + unit: "min", + options: minuteOptions, + selection: binding(get: { $0 % 60 }, set: { $0 / 60 * 60 + $1 }), + identifier: "minutes" + ) + } + } + + private func wheel( + _ title: LocalizedStringKey, + unit: LocalizedStringKey, + options: [Int], + selection: Binding, + identifier: String + ) -> some View { + HStack(spacing: DS.Spacing.xs) { + Picker(title, selection: selection) { + ForEach(options, id: \.self) { value in + Text(verbatim: "\(value)").tag(value) + } + } + .pickerStyle(.wheel) + .labelsHidden() + .accessibilityLabel(title) + .accessibilityIdentifier(identifierPrefix.map { "\($0).\(identifier)" } ?? identifier) + Text(unit) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity) + } + + /// Projects one wheel onto the shared total, clamped to the picker's range. + private func binding( + get component: @escaping (Int) -> Int, + set combine: @escaping (Int, Int) -> Int + ) -> Binding { + Binding( + get: { component(minutes) }, + set: { minutes = min(max(combine(minutes, $0), 0), maxMinutes) } + ) + } +} diff --git a/Normal/Shared/Components/MessageView.swift b/Normal/Shared/Components/MessageView.swift index c038910..48234fc 100644 --- a/Normal/Shared/Components/MessageView.swift +++ b/Normal/Shared/Components/MessageView.swift @@ -1,11 +1,20 @@ import SwiftUI struct MessageView: View { - let message: String + let text: Text let color: Color + init(message: String, color: Color) { + self.init(text: Text(message), color: color) + } + + init(text: Text, color: Color) { + self.text = text + self.color = color + } + var body: some View { - Text(message) + text .font(.subheadline) .foregroundStyle(.primary) .padding() @@ -18,3 +27,10 @@ struct MessageView: View { ) } } + +extension View { + func standaloneListRow() -> some View { + listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } +} diff --git a/Normal/Shared/Components/StatusBadge.swift b/Normal/Shared/Components/StatusBadge.swift index 73bc9fa..d5f760e 100644 --- a/Normal/Shared/Components/StatusBadge.swift +++ b/Normal/Shared/Components/StatusBadge.swift @@ -6,9 +6,13 @@ struct StatusBadge: View { let tint: Color var body: some View { - Label(title, systemImage: systemImage) - .font(.caption.weight(.medium)) - .foregroundStyle(tint) - .accessibilityLabel(Text(title)) + HStack(spacing: DS.Spacing.xs) { + Image(systemName: systemImage) + Text(title) + } + .font(.caption.weight(.medium)) + .foregroundStyle(tint) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(title)) } } diff --git a/Normal/Shared/Components/SummaryRow.swift b/Normal/Shared/Components/SummaryRow.swift new file mode 100644 index 0000000..09d3802 --- /dev/null +++ b/Normal/Shared/Components/SummaryRow.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct SummaryRow: View { + let systemImage: String + let tint: Color + let title: Text + let subtitle: Text + + var body: some View { + HStack(spacing: DS.Spacing.md) { + Image(systemName: systemImage) + .font(.title) + .foregroundStyle(tint) + .frame(width: DS.Size.summaryIcon) + .accessibilityHidden(true) + + VStack(alignment: .leading) { + title + .font(.headline) + subtitle + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Normal/Shared/Util/DurationFormat.swift b/Normal/Shared/Util/DurationFormat.swift new file mode 100644 index 0000000..0f3d6d2 --- /dev/null +++ b/Normal/Shared/Util/DurationFormat.swift @@ -0,0 +1,23 @@ +import Foundation + +enum DurationFormat { + /// "45m", "2h", "1h 30m" — the compact form used on schedule cards and + /// usage limits. + static func compact(minutes: Int) -> String { + let hours = minutes / 60 + let remainder = minutes % 60 + if hours == 0 { return "\(remainder)m" } + if remainder == 0 { return "\(hours)h" } + return "\(hours)h \(remainder)m" + } + + /// "45 minutes", "2 hours", "1 hour, 30 minutes" — for body copy. + /// + /// Delegates pluralisation and locale ordering to Foundation rather than + /// hand-assembling the string. + static func spelled(minutes: Int) -> String { + Duration.seconds(minutes * 60).formatted( + .units(allowed: [.hours, .minutes], width: .wide, zeroValueUnits: .hide) + ) + } +} diff --git a/Normal/Shared/Util/FamilyControlsUtil.swift b/Normal/Shared/Util/FamilyControlsUtil.swift index ac5b8cd..c3eaa05 100644 --- a/Normal/Shared/Util/FamilyControlsUtil.swift +++ b/Normal/Shared/Util/FamilyControlsUtil.swift @@ -60,6 +60,22 @@ extension FamilyActivitySelection { } } + func subtracting(_ other: FamilyActivitySelection) -> FamilyActivitySelection { + var result = self + result.applicationTokens.subtract(other.applicationTokens) + result.webDomainTokens.subtract(other.webDomainTokens) + result.categoryTokens.subtract(other.categoryTokens) + return result + } + + func intersection(_ other: FamilyActivitySelection) -> FamilyActivitySelection { + var result = self + result.applicationTokens.formIntersection(other.applicationTokens) + result.webDomainTokens.formIntersection(other.webDomainTokens) + result.categoryTokens.formIntersection(other.categoryTokens) + return result + } + func isSubset(of other: FamilyActivitySelection) -> Bool { applicationTokens.isSubset(of: other.applicationTokens) && webDomainTokens.isSubset(of: other.webDomainTokens) @@ -72,7 +88,7 @@ enum SelectedTokenKind: Hashable { case webDomain(WebDomainToken) case category(ActivityCategoryToken) - init?(_ hashable: AnyHashable) { + nonisolated init?(_ hashable: AnyHashable) { let base = hashable.base if let token = base as? ApplicationToken { self = .application(token); return } if let token = base as? WebDomainToken { self = .webDomain(token); return } diff --git a/Normal/Shared/Util/TimeIntervalUtil.swift b/Normal/Shared/Util/TimeIntervalUtil.swift index dfc2bb4..0ab31e7 100644 --- a/Normal/Shared/Util/TimeIntervalUtil.swift +++ b/Normal/Shared/Util/TimeIntervalUtil.swift @@ -1,6 +1,9 @@ import Foundation -extension TimeInterval { +/// Pure arithmetic, so explicitly nonisolated — the project defaults to +/// `MainActor` isolation, which would otherwise put these out of reach of +/// nonisolated types like `UsageLimitEditPolicy`. +nonisolated extension TimeInterval { static func days(_ count: Int) -> TimeInterval { TimeInterval(count) * 86400 } static func hours(_ count: Int) -> TimeInterval { TimeInterval(count) * 3600 } static func minutes(_ count: Int) -> TimeInterval { TimeInterval(count) * 60 } diff --git a/Normal/Views/Home/BlockStatusView.swift b/Normal/Views/Home/BlockStatusView.swift index c90b755..77e3a01 100644 --- a/Normal/Views/Home/BlockStatusView.swift +++ b/Normal/Views/Home/BlockStatusView.swift @@ -31,18 +31,11 @@ struct BlockStatusView: View { } private var statusRow: some View { - HStack(spacing: DS.Spacing.lg - 1) { - Image(systemName: status.icon) - .font(.title) - .foregroundStyle(status.color) - - VStack(alignment: .leading) { - Text(status.title) - .font(.headline) - Text("\(screenTimeService.activeShieldCount()) of \(totalCount) Blocked") - .font(.caption) - .foregroundStyle(.secondary) - } - } + SummaryRow( + systemImage: status.icon, + tint: status.color, + title: Text(LocalizedStringKey(status.title)), + subtitle: Text("\(screenTimeService.activeShieldCount()) of \(totalCount) Blocked") + ) } } diff --git a/Normal/Views/Home/StatusDetailView.swift b/Normal/Views/Home/StatusDetailView.swift index 3bd9567..6b79f00 100644 --- a/Normal/Views/Home/StatusDetailView.swift +++ b/Normal/Views/Home/StatusDetailView.swift @@ -4,7 +4,9 @@ import SwiftUI struct StatusDetailView: View { @Environment(ScreenTimeService.self) private var screenTimeService + @Environment(UsageLimitService.self) private var usageLimitService @Query(sort: [SortDescriptor(\BlockSchedule.sortIndex)]) private var schedules: [BlockSchedule] + @Query(sort: [SortDescriptor(\UsageLimit.sortIndex)]) private var limits: [UsageLimit] @Query private var allSettings: [Settings] let mainSelection: SelectedApps @@ -26,6 +28,7 @@ struct StatusDetailView: View { var body: some View { List { overallSection + if !limits.isEmpty { limitsSection } tokenSection("Apps", kinds: selection.applicationTokens.sortedStably.map(SelectedTokenKind.application)) tokenSection("Websites", kinds: selection.webDomainTokens.sortedStably.map(SelectedTokenKind.webDomain)) tokenSection("Categories", kinds: selection.categoryTokens.sortedStably.map(SelectedTokenKind.category)) @@ -38,17 +41,45 @@ struct StatusDetailView: View { private var overallSection: some View { Section("Status") { - HStack(spacing: DS.Spacing.lg - 1) { - Image(systemName: overallStatus.icon) - .font(.title) - .foregroundStyle(overallStatus.color) - VStack(alignment: .leading) { - Text(overallStatus.title) - .font(.headline) - Text("\(screenTimeService.activeShieldCount()) of \(selection.count + customDomains.count) blocked") - .font(.caption) - .foregroundStyle(.secondary) - } + SummaryRow( + systemImage: overallStatus.icon, + tint: overallStatus.color, + title: Text(LocalizedStringKey(overallStatus.title)), + subtitle: Text( + "\(screenTimeService.activeShieldCount()) of \(selection.count + customDomains.count) blocked" + ) + ) + } + } + + private var limitsSection: some View { + let states = usageLimitService.states(for: limits) + let isPaused = usageLimitService.isDayOverridden() + let reset = usageLimitService.nextReset().formatted(date: .omitted, time: .shortened) + return Section { + ForEach(limits) { limit in + limitRow(limit, state: states[limit.id] ?? .under, isPaused: isPaused) + } + } header: { + Text("Max Daily Limits") + } footer: { + Text(isPaused ? "Paused by emergency unblock until \(reset)." : "Resets at \(reset).") + } + } + + private func limitRow(_ limit: UsageLimit, state: UsageLimitState, isPaused: Bool) -> some View { + HStack { + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + SelectionIconsView(tokens: limit.selection.allTokens, limit: 5) + Text("\(limit.selection.selectedTokenCounts) · \(DurationFormat.compact(minutes: limit.minutesPerDay)) a day") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isPaused { + StatusBadge(title: "Paused", systemImage: "pause.circle.fill", tint: .secondary) + } else { + StatusBadge(title: state.label, systemImage: state.icon, tint: state.color) } } } diff --git a/Normal/Views/Schedules/ScheduleFormSheet.swift b/Normal/Views/Schedules/ScheduleFormSheet.swift index 6dc7875..33483d2 100644 --- a/Normal/Views/Schedules/ScheduleFormSheet.swift +++ b/Normal/Views/Schedules/ScheduleFormSheet.swift @@ -237,12 +237,7 @@ struct ScheduleFormSheet: View { } private var formattedComputedDuration: String { - let total = computedDurationMinutes - let hours = total / 60 - let minutes = total % 60 - if hours == 0 { return "\(minutes)m" } - if minutes == 0 { return "\(hours)h" } - return "\(hours)h \(minutes)m" + DurationFormat.compact(minutes: computedDurationMinutes) } private var weekdaySection: some View { diff --git a/Normal/Views/Settings/GeneralSettingsView.swift b/Normal/Views/Settings/GeneralSettingsView.swift index d6d3fd7..d319772 100644 --- a/Normal/Views/Settings/GeneralSettingsView.swift +++ b/Normal/Views/Settings/GeneralSettingsView.swift @@ -13,6 +13,7 @@ struct GeneralSettingsView: View { List { unblockingSection blockingSection + UsageSummaryView() customDomainsSection appearanceSection aboutSection diff --git a/Normal/Views/Settings/SettingsView.swift b/Normal/Views/Settings/SettingsView.swift index bd41fa0..b71b3fe 100644 --- a/Normal/Views/Settings/SettingsView.swift +++ b/Normal/Views/Settings/SettingsView.swift @@ -5,7 +5,9 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(ScreenTimeService.self) private var screenTimeService @Environment(ScheduleService.self) private var scheduleService + @Environment(TimedUnblockService.self) private var timedUnblockService @Environment(EmergencyUnblockService.self) private var emergencyUnblockService + @Environment(UsageLimitService.self) private var usageLimitService @Query private var allSettings: [Settings] @Query private var keys: [Key] @Query private var schedules: [BlockSchedule] @@ -75,8 +77,13 @@ struct SettingsView: View { private func performEmergencyUnblock() { emergencyUnblockService.record(into: settings) - screenTimeService.removeShieldOnAll(blockAllPreventsAppDelete: true) - scheduleService.disableAll(schedules, screenTimeService: screenTimeService) + BlockActions.emergencyUnblock( + schedules: schedules, + screenTimeService: screenTimeService, + timedUnblockService: timedUnblockService, + scheduleService: scheduleService, + usageLimitService: usageLimitService + ) showSuccessAlert = true } } diff --git a/Normal/Views/Settings/UnblockDurationsView.swift b/Normal/Views/Settings/UnblockDurationsView.swift index 04d8596..e336910 100644 --- a/Normal/Views/Settings/UnblockDurationsView.swift +++ b/Normal/Views/Settings/UnblockDurationsView.swift @@ -121,14 +121,12 @@ struct AddUnblockDurationSheet: View { let existing: [TimedUnblockDuration] let onAdd: (TimedUnblockDuration) -> Void - @State private var hours = 0 - @State private var minutes = 30 + @State private var totalMinutes = 30 - private static let minuteOptions = Array(stride(from: 0, to: 60, by: TimedUnblockDuration.stepSeconds / 60)) private static let liveActivityLimitHours = 8 private var duration: TimedUnblockDuration? { - TimedUnblockDuration(hours: hours, minutes: minutes) + TimedUnblockDuration(validating: totalMinutes * 60) } private var isDuplicate: Bool { @@ -141,12 +139,12 @@ struct AddUnblockDurationSheet: View { NavigationStack { Form { Section { - HStack(spacing: 0) { - wheel("Hours", unit: "hr", selection: $hours, options: Array(0 ..< 24)) - .accessibilityIdentifier("durationPicker.hours") - wheel("Minutes", unit: "min", selection: $minutes, options: Self.minuteOptions) - .accessibilityIdentifier("durationPicker.minutes") - } + DurationWheelPicker( + minutes: $totalMinutes, + maxMinutes: TimedUnblockDuration.maximumSeconds / 60, + minuteStep: TimedUnblockDuration.stepSeconds / 60, + identifierPrefix: "durationPicker" + ) } header: { if let duration { Text(duration.label) @@ -181,33 +179,11 @@ struct AddUnblockDurationSheet: View { } else if isDuplicate { Text("This duration is already in your list.") .foregroundStyle(.red) - } else if hours >= Self.liveActivityLimitHours { + } else if totalMinutes / 60 >= Self.liveActivityLimitHours { Text("Live Activity countdowns end after \(Self.liveActivityLimitHours) hours. Apps still re-block on time.") } } - private func wheel( - _ title: LocalizedStringKey, - unit: LocalizedStringKey, - selection: Binding, - options: [Int] - ) -> some View { - HStack(spacing: DS.Spacing.xs) { - Picker(title, selection: selection) { - ForEach(options, id: \.self) { value in - Text(verbatim: "\(value)").tag(value) - } - } - .pickerStyle(.wheel) - .labelsHidden() - .accessibilityLabel(title) - Text(unit) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - .frame(maxWidth: .infinity) - } - private func add() { guard let duration, canAdd else { return } onAdd(duration) diff --git a/Normal/Views/Usage/UsageLimitEditor.swift b/Normal/Views/Usage/UsageLimitEditor.swift new file mode 100644 index 0000000..0db4de9 --- /dev/null +++ b/Normal/Views/Usage/UsageLimitEditor.swift @@ -0,0 +1,272 @@ +import FamilyControls +import SwiftData +import SwiftUI + +/// What the editor is about to write. Keeps the "new" and "edit" flows on one +/// screen instead of duplicating the picker and the weekly-lock handling. +enum UsageLimitTarget: Identifiable { + case existing(UsageLimit) + case new + + var id: String { + switch self { + case let .existing(limit): limit.id.uuidString + case .new: "new" + } + } + + var limit: UsageLimit? { + if case let .existing(limit) = self { return limit } + return nil + } +} + +struct UsageLimitEditor: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Environment(UsageLimitService.self) private var usageLimitService + @Environment(ScreenTimeService.self) private var screenTimeService + + @Query private var limits: [UsageLimit] + @Query private var selectedApps: [SelectedApps] + @Query private var allSettings: [Settings] + + let target: UsageLimitTarget + + @State private var minutes: Int + @State private var selection: FamilyActivitySelection + @State private var isPickingApps = false + @State private var showDeleteConfirmation = false + + private static let defaultMinutes = 60 + + init(target: UsageLimitTarget) { + self.target = target + _minutes = State(initialValue: target.limit?.minutesPerDay ?? Self.defaultMinutes) + _selection = State(initialValue: target.limit?.selection ?? FamilyActivitySelection()) + } + + private var existing: UsageLimit? { target.limit } + private var settings: Settings { allSettings.unwrapped } + + private var isTooShort: Bool { minutes < UsageLimit.minimumMinutes } + + private var hasApps: Bool { !selection.isEmpty } + + private var hasChange: Bool { + minutes != existing?.minutesPerDay || selection != existing?.selection + } + + /// Apps removed from what the limit meters, i.e. less is watched than before. + private var dropsCoverage: Bool { + guard let existing else { return false } + return !existing.selection.isSubset(of: selection) + } + + private var edit: UsageLimitEdit { + guard let existing else { return .create(minutes: minutes) } + return .adjust(from: existing.minutesPerDay, to: minutes, dropsCoverage: dropsCoverage) + } + + private func decision(at now: Date) -> UsageLimitEditDecision { + existing?.decide(edit, now: now) ?? .allowed(.tightening) + } + + private func deleteDecision(at now: Date) -> UsageLimitEditDecision { + guard let existing else { return .allowed(.tightening) } + return existing.decide(.delete(minutes: existing.minutesPerDay), now: now) + } + + private var duplicates: FamilyActivitySelection { + let others = limits + .filter { $0.id != existing?.id } + .reduce(FamilyActivitySelection()) { $0.union($1.selection) } + let added = existing.map { selection.subtracting($0.selection) } ?? selection + return added.intersection(others) + } + + private func canSave(at now: Date) -> Bool { + hasChange && !isTooShort && hasApps && duplicates.isEmpty && decision(at: now).isAllowed + } + + private var combinedSelection: FamilyActivitySelection { + limits + .filter { $0.id != existing?.id } + .reduce(selectedApps.first?.selection ?? FamilyActivitySelection()) { $0.union($1.selection) } + .union(selection) + } + + var body: some View { + NavigationStack { + TimelineView(UsageLockSchedule(dates: existing?.lockRefreshDates ?? [])) { context in + form(now: context.date) + } + .familyActivityPicker(isPresented: $isPickingApps, selection: $selection) + .navigationTitle(existing == nil ? "New Limit" : "Edit Limit") + .navigationBarTitleDisplayMode(.inline) + .deleteConfirmation( + title: "Delete Limit?", + itemName: String(localized: "This limit"), + isPresented: $showDeleteConfirmation, + onDelete: delete + ) + } + } + + private func form(now: Date) -> some View { + Form { + if let notice = lockNotice(at: now) { lockSection(notice) } + AppSelectLimitBannerView(selection: combinedSelection) + appsSection + timeSection + if existing != nil { deleteSection(now: now) } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(existing == nil ? "Save" : "Update", action: save) + .fontWeight(.semibold) + .disabled(!canSave(at: now)) + .accessibilityIdentifier("usage.save") + } + } + } + + private var appsSection: some View { + Section { + Button { screenTimeService.ifAuthorized { isPickingApps = true } } label: { + CountChevronRow(title: "Select Apps", count: selection.count) + } + .accessibilityIdentifier("usage.selectApps") + + if hasApps { + Text(verbatim: selection.selectedTokenCounts) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Apps") + } footer: { + if duplicates.isEmpty { + Text( + hasApps + ? "These apps, websites, and categories share one allowance." + : "Choose the apps, websites, or categories this limit measures." + ) + } else { + duplicatesWarning + } + } + } + + private var duplicatesWarning: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + SelectionIconsView(tokens: duplicates.allTokens, limit: 5) + Text("Already in another limit. Remove them here, or change that limit instead.") + .foregroundStyle(.red) + } + .accessibilityIdentifier("usage.duplicates") + } + + private var timeSection: some View { + Section { + DurationWheelPicker( + minutes: $minutes, + maxMinutes: UsageLimit.maximumMinutes, + identifierPrefix: "usage.duration" + ) + } header: { + Text("Time Per Day") + } footer: { + if isTooShort { + Text("Limits must be at least \(UsageLimit.minimumMinutes) minutes.") + .foregroundStyle(.red) + } else { + Text(explanation) + } + } + } + + private var explanation: LocalizedStringKey { + let reset = usageLimitService.nextReset().formatted(date: .omitted, time: .shortened) + return "After \(DurationFormat.spelled(minutes: minutes)) of combined use, these stay blocked until \(reset), even while everything else is unblocked. Time already used today counts; if you're already past it, the limit starts at the next reset." + } + + private func lockNotice(at now: Date) -> (text: Text, color: Color)? { + guard let existing else { + return (Text("After saving, you have 10 minutes to adjust this limit. Then raising or deleting it is allowed once every 7 days."), .blue) + } + switch existing.lockState(now: now) { + case .unlocked: + return nil + case let .grace(until): + return (Text("You can raise, delete, or remove apps from this limit for another \(UsageLockDeadline.countdown(to: until, now: now))."), .blue) + case let .locked(until): + return (Text("You can lower this limit or add apps anytime. Raising it, removing apps, or deleting it is available again \(UsageLockDeadline.phrase(until: until, now: now))."), .orange) + } + } + + private func lockSection(_ notice: (text: Text, color: Color)) -> some View { + Section { + MessageView(text: notice.text, color: notice.color) + .standaloneListRow() + .accessibilityIdentifier("usage.locked") + } + } + + private func deleteSection(now: Date) -> some View { + Section { + Button(role: .destructive) { showDeleteConfirmation = true } label: { + Text("Delete Limit").frame(maxWidth: .infinity) + } + .disabled(!deleteDecision(at: now).isAllowed) + .accessibilityIdentifier("usage.delete") + } footer: { + if case let .locked(until) = deleteDecision(at: now) { + Text("Deleting a limit loosens it. You can delete it \(UsageLockDeadline.phrase(until: until, now: now)).") + } + } + } + + // MARK: - Actions + + private func save() { + guard case let .allowed(allowance) = decision(at: .now) else { return } + + var updated = limits + if let existing { + existing.minutesPerDay = minutes + existing.selection = selection + existing.commit(allowance) + } else { + let limit = makeLimit() + modelContext.insert(limit) + // `@Query` has not refreshed yet, so fold the new limit in by hand. + updated.append(limit) + } + + usageLimitService.registerAll(updated, config: settings.usageLimitConfig) + if let existing, allowance != .tightening { + usageLimitService.clearState(for: existing) + } + dismiss() + } + + private func makeLimit() -> UsageLimit { + UsageLimit( + selection: selection, + minutesPerDay: minutes, + sortIndex: SortIndexing.nextIndex(after: limits, sortIndex: \.sortIndex) + ) + } + + private func delete() { + guard let existing, deleteDecision(at: .now).isAllowed else { return } + let remaining = limits.filter { $0.id != existing.id } + modelContext.delete(existing) + usageLimitService.registerAll(remaining, config: settings.usageLimitConfig) + dismiss() + } +} diff --git a/Normal/Views/Usage/UsageLimitRow.swift b/Normal/Views/Usage/UsageLimitRow.swift new file mode 100644 index 0000000..8ab00e3 --- /dev/null +++ b/Normal/Views/Usage/UsageLimitRow.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct UsageLimitRow: View { + let limit: UsageLimit + let state: UsageLimitState + let now: Date + + var body: some View { + HStack(spacing: DS.Spacing.md) { + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + HStack(spacing: DS.Spacing.sm) { + SelectionIconsView(tokens: limit.selection.allTokens, limit: 5) + } + Text(verbatim: limit.selection.selectedTokenCounts) + .font(.caption) + .foregroundStyle(.secondary) + StatusBadge(title: state.label, systemImage: state.icon, tint: state.color) + if case let .grace(until) = limit.lockState(now: now) { + HStack(spacing: DS.Spacing.xs) { + Image(systemName: "pencil") + Text("\(UsageLockDeadline.countdown(to: until, now: now)) left to adjust") + } + .font(.caption.weight(.medium)) + .foregroundStyle(.orange) + .accessibilityIdentifier("usage.graceCountdown") + } + } + Spacer(minLength: DS.Spacing.sm) + Text(DurationFormat.compact(minutes: limit.minutesPerDay)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + .accessibilityLabel(DurationFormat.spelled(minutes: limit.minutesPerDay)) + } + .padding(.vertical, DS.Spacing.xs) + } +} diff --git a/Normal/Views/Usage/UsageLimitState+Presentation.swift b/Normal/Views/Usage/UsageLimitState+Presentation.swift new file mode 100644 index 0000000..88d964e --- /dev/null +++ b/Normal/Views/Usage/UsageLimitState+Presentation.swift @@ -0,0 +1,27 @@ +import SwiftUI + +extension UsageLimitState { + var label: LocalizedStringKey { + switch self { + case .under: "Available" + case .warning: "Almost Reached" + case .reached: "Limit Reached" + } + } + + var icon: String { + switch self { + case .under: "hourglass" + case .warning: "hourglass.bottomhalf.filled" + case .reached: "lock.fill" + } + } + + var color: Color { + switch self { + case .under: .green + case .warning: .orange + case .reached: .red + } + } +} diff --git a/Normal/Views/Usage/UsageLimitsGuideView.swift b/Normal/Views/Usage/UsageLimitsGuideView.swift new file mode 100644 index 0000000..5484b92 --- /dev/null +++ b/Normal/Views/Usage/UsageLimitsGuideView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct UsageLimitsGuideView: View { + let onAddLimit: () -> Void + + var body: some View { + Section { + VStack(alignment: .leading, spacing: DS.Spacing.xl) { + header + Button("Add Limit", action: onAddLimit) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("usage.emptyAddLimit") + } + .padding(.vertical, DS.Spacing.sm) + .standaloneListRow() + } + + Section("How It Works") { + FeatureRow(systemImage: "square.grid.2x2.fill", text: "Pick apps, websites, or categories. You can group multiple apps in a single limit.") + FeatureRow(systemImage: "timer", text: "Choose how much time they get each day. Time you've already used today counts.") + FeatureRow(systemImage: "lock.fill", text: "When the time runs out, they stay blocked until the reset. Only Emergency Unblock lifts them early.", tint: .red) + FeatureRow(systemImage: "pencil", text: "Tighten anytime: lowering a limit or adding apps always works. After creating a limit raising or deleting it is allowed once every 7 days.", tint: .orange) + FeatureRow(systemImage: "arrow.counterclockwise", text: "Every limit resets at the time you set. Once you have limits, it can be changed once every 7 days. Unused time doesn't carry over.") + } + + Section("Ideas") { + FeatureRow(systemImage: "bubble.left.and.bubble.right.fill", text: "30 minutes a day across all your social apps", tint: .orange) + FeatureRow(systemImage: "gamecontroller.fill", text: "1 hour a day for games", tint: .orange) + FeatureRow(systemImage: "newspaper.fill", text: "15 minutes a day for news or shopping websites", tint: .orange) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + Image(systemName: "hourglass") + .font(.largeTitle) + .foregroundStyle(.tint) + Text("No Max Daily Limits").font(.title2.bold()) + Text("Cap how long you can use apps each day. Once a limit runs out, those apps stay blocked until the day is over, even if you unblock with a key.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } +} diff --git a/Normal/Views/Usage/UsageLockDeadline.swift b/Normal/Views/Usage/UsageLockDeadline.swift new file mode 100644 index 0000000..4b2ac96 --- /dev/null +++ b/Normal/Views/Usage/UsageLockDeadline.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct UsageLockSchedule: TimelineSchedule { + let dates: [Date] + + func entries(from startDate: Date, mode _: TimelineScheduleMode) -> [Date] { + [startDate] + dates.filter { $0 > startDate }.sorted() + } +} + +enum UsageLockDeadline { + static func phrase(until: Date, now: Date) -> Text { + if until.timeIntervalSince(now) > UsageLimitEditPolicy.countdownLead { + return Text("on \(until.formatted(date: .abbreviated, time: .standard))") + } + return Text("in \(countdown(to: until, now: now))") + } + + static func countdown(to until: Date, now: Date) -> Text { + Text(timerInterval: now ... max(now, until), countsDown: true) + .monospacedDigit() + } +} diff --git a/Normal/Views/Usage/UsageSummaryView.swift b/Normal/Views/Usage/UsageSummaryView.swift new file mode 100644 index 0000000..63b7fce --- /dev/null +++ b/Normal/Views/Usage/UsageSummaryView.swift @@ -0,0 +1,42 @@ +import SwiftData +import SwiftUI + +struct UsageSummaryView: View { + @Environment(UsageLimitService.self) private var usageLimitService + + @Query(sort: [SortDescriptor(\UsageLimit.sortIndex)]) private var limits: [UsageLimit] + + var body: some View { + let states = usageLimitService.states(for: limits) + let reachedCount = states.values.filter { $0 == .reached }.count + let worstState = states.values.max() ?? .under + + Section("Max Daily Limits") { + NavigationLink { + UsageView() + } label: { + SummaryRow( + systemImage: limits.isEmpty ? "hourglass" : worstState.icon, + tint: limits.isEmpty ? .secondary : worstState.color, + title: title(reachedCount: reachedCount), + subtitle: subtitle(reachedCount: reachedCount, worstState: worstState) + ) + } + .accessibilityIdentifier("settings.maxDailyLimitsLink") + } + } + + private func title(reachedCount: Int) -> Text { + if limits.isEmpty { return Text("No Max Daily Limits") } + if reachedCount > 0 { return Text("\(reachedCount) of \(limits.count) Reached") } + return Text("\(limits.count) Max Daily Limits") + } + + private func subtitle(reachedCount: Int, worstState: UsageLimitState) -> Text { + if limits.isEmpty { return Text("Hard cap on daily app use") } + let reset = usageLimitService.nextReset().formatted(date: .omitted, time: .shortened) + if usageLimitService.isDayOverridden() { return Text("Paused until \(reset)") } + if reachedCount > 0 { return Text("Resets at \(reset)") } + return Text(worstState.label) + } +} diff --git a/Normal/Views/Usage/UsageView.swift b/Normal/Views/Usage/UsageView.swift new file mode 100644 index 0000000..5828874 --- /dev/null +++ b/Normal/Views/Usage/UsageView.swift @@ -0,0 +1,171 @@ +import SwiftData +import SwiftUI + +struct UsageView: View { + @Environment(UsageLimitService.self) private var usageLimitService + + @Query(sort: [SortDescriptor(\UsageLimit.sortIndex)]) private var limits: [UsageLimit] + @Query private var allSettings: [Settings] + + @State private var editing: UsageLimitTarget? + + private var settings: Settings { allSettings.unwrapped } + + var body: some View { + TimelineView(UsageLockSchedule(dates: limits.flatMap(\.lockRefreshDates))) { context in + List { + noticeSection + if limits.isEmpty { + UsageLimitsGuideView { editing = .new } + } else { + limitsSection(now: context.date) + } + resetSection + } + } + .navigationTitle("Max Daily Limits") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { editing = .new } label: { + Label("Add Limit", systemImage: "plus") + } + .accessibilityIdentifier("usage.addLimit") + } + } + .sheet(item: $editing) { UsageLimitEditor(target: $0) } + } + + @ViewBuilder + private var noticeSection: some View { + if !limits.isEmpty, usageLimitService.isDayOverridden() { + let reset = usageLimitService.nextReset().formatted(date: .omitted, time: .shortened) + Section { + MessageView(text: Text("Paused by emergency unblock until \(reset)."), color: .orange) + .standaloneListRow() + .accessibilityIdentifier("usage.notice") + } + } + } + + private func limitsSection(now: Date) -> some View { + let states = usageLimitService.states(for: limits) + return Section { + ForEach(limits) { limit in + Button { editing = .existing(limit) } label: { + UsageLimitRow(limit: limit, state: states[limit.id] ?? .under, now: now) + } + .tint(.primary) + .accessibilityHint("Edit limit") + } + } footer: { + Text("Once a limit runs out, its apps stay blocked until the reset, even while everything else is unblocked.") + } + } + + private var resetSection: some View { + Section { + NavigationLink { + UsageResetTimeView(currentMinutes: settings.dailyLimitResetMinutes) + } label: { + LabeledContent("Resets At") { + Text(usageLimitService.nextReset(), style: .time) + } + } + .accessibilityIdentifier("usage.resetTime") + } footer: { + Text("Unused time does not carry over.") + } + } +} + +struct UsageResetTimeView: View { + @Environment(\.dismiss) private var dismiss + @Environment(UsageLimitService.self) private var usageLimitService + + @Query private var limits: [UsageLimit] + @Query private var allSettings: [Settings] + + @State private var time: Date + + init(currentMinutes: Int) { + let time = Calendar.current.date( + bySettingHour: currentMinutes / 60, + minute: currentMinutes % 60, + second: 0, + of: .now + ) + _time = State(initialValue: time ?? .now) + } + + private var settings: Settings { allSettings.unwrapped } + + private var newMinutes: Int { + let parts = Calendar.current.dateComponents([.hour, .minute], from: time) + return (parts.hour ?? 0) * 60 + (parts.minute ?? 0) + } + + private var hasChange: Bool { newMinutes != settings.dailyLimitResetMinutes } + + private func decision(at now: Date) -> UsageLimitEditDecision { + limits.isEmpty ? .allowed(.tightening) : usageLimitService.decideResetChange(now: now) + } + + private func notice(at now: Date) -> Text? { + guard !limits.isEmpty else { return nil } + switch usageLimitService.resetLockState(now: now) { + case .unlocked: + return nil + case let .grace(until): + return Text("You can change the reset time for another \(UsageLockDeadline.countdown(to: until, now: now)).") + case let .locked(until): + return Text("You can change the reset time again \(UsageLockDeadline.phrase(until: until, now: now)).") + } + } + + var body: some View { + TimelineView(UsageLockSchedule(dates: usageLimitService.resetLockRefreshDates())) { context in + form(now: context.date) + } + .navigationTitle("Reset Time") + .navigationBarTitleDisplayMode(.inline) + } + + private func form(now: Date) -> some View { + Form { + if let notice = notice(at: now) { + Section { + MessageView(text: notice, color: .orange) + .standaloneListRow() + .accessibilityIdentifier("usage.resetNotice") + } + } + + Section { + DatePicker("Resets At", selection: $time, displayedComponents: .hourAndMinute) + .datePickerStyle(.wheel) + .labelsHidden() + .frame(maxWidth: .infinity) + .accessibilityIdentifier("usage.resetPicker") + } footer: { + Text("Every limit's time resets at this time each day. While you have limits, it can be changed once every 7 days, with 10 minutes to adjust after each change.") + } + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save", action: save) + .fontWeight(.semibold) + .disabled(!hasChange || !decision(at: now).isAllowed) + .accessibilityIdentifier("usage.resetSave") + } + } + } + + private func save() { + guard case let .allowed(allowance) = decision(at: .now) else { return } + usageLimitService.commitResetChange(allowance) + settings.dailyLimitResetMinutes = newMinutes + usageLimitService.registerAll(limits, config: settings.usageLimitConfig) + dismiss() + } +} diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index 1ff03bc..d74fd34 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -6,10 +6,17 @@ import ManagedSettings final class NormalMonitor: DeviceActivityMonitor { private let sharedStore = SharedStore() private let store = ManagedSettingsStore() + private let limitStore = ManagedSettingsStore(named: .dailyLimits) + + private static let thresholdGraceSeconds: TimeInterval = 10 override func intervalDidStart(for activity: DeviceActivityName) { let name = activity.rawValue - if name.hasPrefix("schedule_") { + if name == SharedConstants.usageLimitsActivityName { + sharedStore.resetUsageDayIfStale() + sharedStore.saveUsageLimitsIntervalStart(.now) + limitStore.syncDailyLimits(from: sharedStore) + } else if name.hasPrefix("schedule_") { handleScheduleIntervalStart(activityName: name) } } @@ -23,6 +30,43 @@ final class NormalMonitor: DeviceActivityMonitor { } } + override func eventDidReachThreshold( + _ event: DeviceActivityEvent.Name, + activity _: DeviceActivityName + ) { + guard let limit = credibleUsageLimit(eventName: event.rawValue, minutes: \.minutesPerDay) else { return } + sharedStore.recordUsageState(.reached, for: limit.id) + limitStore.syncDailyLimits(from: sharedStore) + } + + override func eventWillReachThresholdWarning( + _ event: DeviceActivityEvent.Name, + activity _: DeviceActivityName + ) { + guard let limit = credibleUsageLimit( + eventName: event.rawValue, + minutes: { $0.minutesPerDay - SharedConstants.usageLimitWarningMinutes } + ) else { return } + sharedStore.recordUsageState(.warning, for: limit.id) + } + + private func credibleUsageLimit( + eventName: String, + minutes: (UsageLimitDTO) -> Int + ) -> UsageLimitDTO? { + guard !sharedStore.isUsageDayOverridden(), + let id = SharedConstants.usageLimitID(fromEventName: eventName), + let limit = sharedStore.loadUsageLimits().first(where: { $0.id == id }) + else { return nil } + + if let start = sharedStore.loadUsageLimitsIntervalStart(), + Date.now.timeIntervalSince(start) < Self.thresholdGraceSeconds { + return nil + } + guard sharedStore.usagePeriod.couldHaveMetered(minutes: minutes(limit), by: .now) else { return nil } + return limit + } + private func handleTimedUnblockExpired(activityName: String) { guard let dto = sharedStore.findTimedUnblock(activityName: activityName), let selection = try? FamilyActivitySelection.fromData(dto.selectionData) diff --git a/NormalTests/Models/UsageLimitEditPolicyTests.swift b/NormalTests/Models/UsageLimitEditPolicyTests.swift new file mode 100644 index 0000000..691888c --- /dev/null +++ b/NormalTests/Models/UsageLimitEditPolicyTests.swift @@ -0,0 +1,287 @@ +import FamilyControls +import Foundation +@testable import Normal +import Testing + +struct UsageLimitEditPolicyTests { + private let anchor = Date(timeIntervalSinceReferenceDate: 1_000_000) + + // MARK: - Direction + + @Test func creatingALimitIsNeverLoosening() { + #expect(UsageLimitEdit.create(minutes: 60).isLoosening == false) + } + + @Test func loweringALimitIsNeverLoosening() { + #expect(UsageLimitEdit.adjust(from: 60, to: 30, dropsCoverage: false).isLoosening == false) + } + + @Test func raisingALimitIsLoosening() { + #expect(UsageLimitEdit.adjust(from: 30, to: 60, dropsCoverage: false).isLoosening) + } + + /// Narrowing what a limit meters is a loosening too, or the cooldown could + /// be sidestepped by shrinking the app set instead of raising the minutes. + @Test func narrowingWhatALimitCoversIsLoosening() { + #expect(UsageLimitEdit.adjust(from: 60, to: 60, dropsCoverage: true).isLoosening) + #expect(UsageLimitEdit.adjust(from: 60, to: 30, dropsCoverage: true).isLoosening) + } + + @Test func wideningWhatALimitCoversIsNotLoosening() { + #expect(UsageLimitEdit.adjust(from: 60, to: 60, dropsCoverage: false).isLoosening == false) + } + + @Test func removingALimitIsLoosening() { + #expect(UsageLimitEdit.delete(minutes: 30).isLoosening) + } + + @Test func changingTheResetTimeIsLoosening() { + #expect(UsageLimitEdit.changeReset.isLoosening) + } + + @Test func changingTheResetTimeWaitsOutTheCooldown() { + let decision = UsageLimitEditPolicy.decide( + .changeReset, + anchor: anchor, + now: anchor + .days(3) + ) + #expect(decision == .locked(until: anchor + .days(7))) + } + + @Test func keepingALimitUnchangedIsNotLoosening() { + #expect(UsageLimitEdit.adjust(from: 60, to: 60, dropsCoverage: false).isLoosening == false) + } + + // MARK: - Tightening is always immediate + + @Test func tighteningIsAllowedEvenMidCooldown() { + let decision = UsageLimitEditPolicy.decide( + .adjust(from: 60, to: 15, dropsCoverage: false), + anchor: anchor, + now: anchor + .minutes(30) + ) + #expect(decision == .allowed(.tightening)) + } + + @Test func creatingIsAllowedEvenMidCooldown() { + let decision = UsageLimitEditPolicy.decide( + .create(minutes: 60), + anchor: anchor, + now: anchor + .hours(1) + ) + #expect(decision == .allowed(.tightening)) + } + + // MARK: - The weekly cooldown + + @Test func firstLooseningIsFree() { + let decision = UsageLimitEditPolicy.decide( + .adjust(from: 30, to: 60, dropsCoverage: false), + anchor: nil, + now: anchor + ) + #expect(decision == .allowed(.cooldownElapsed)) + } + + @Test func looseningIsLockedUntilSevenDaysElapse() { + let now = anchor + .days(3) + let decision = UsageLimitEditPolicy.decide( + .adjust(from: 30, to: 60, dropsCoverage: false), + anchor: anchor, + now: now + ) + #expect(decision == .locked(until: anchor + .days(7))) + } + + @Test func looseningIsAllowedOnceSevenDaysElapse() { + let decision = UsageLimitEditPolicy.decide( + .adjust(from: 30, to: 60, dropsCoverage: false), + anchor: anchor, + now: anchor + .days(7) + ) + #expect(decision == .allowed(.cooldownElapsed)) + } + + @Test func deletingIsLockedByTheSameCooldown() { + let decision = UsageLimitEditPolicy.decide( + .delete(minutes: 30), + anchor: anchor, + now: anchor + .days(1) + ) + #expect(decision == .locked(until: anchor + .days(7))) + } + + // MARK: - The 10-minute grace window + + @Test func looseningInsideGraceIsAllowed() { + let decision = UsageLimitEditPolicy.decide( + .adjust(from: 30, to: 60, dropsCoverage: false), + anchor: anchor, + now: anchor + .minutes(9) + ) + #expect(decision == .allowed(.grace)) + } + + @Test func graceEndsAfterTenMinutes() { + let decision = UsageLimitEditPolicy.decide( + .adjust(from: 30, to: 60, dropsCoverage: false), + anchor: anchor, + now: anchor + .minutes(10) + ) + #expect(decision == .locked(until: anchor + .days(7))) + } + + /// The exploit this rule exists to close: if grace re-anchored the clock, + /// chained edits every nine minutes would postpone the cooldown forever. + @Test func graceEditsDoNotPushTheWeeklyClockForward() { + var current: Date? = anchor + for step in stride(from: 1, through: 5, by: 1) { + let now = anchor + .minutes(step) + guard case let .allowed(allowance) = UsageLimitEditPolicy.decide( + .adjust(from: 30, to: 30 + step, dropsCoverage: false), + anchor: current, + now: now + ) else { + Issue.record("edit inside grace should be allowed") + return + } + #expect(allowance == .grace) + current = UsageLimitEditPolicy.anchor(after: allowance, current: current, now: now) + } + #expect(current == anchor) + } + + // MARK: - Anchor bookkeeping + + @Test func onlyCooldownElapsedStartsANewWeek() { + let now = anchor + .days(8) + #expect( + UsageLimitEditPolicy.anchor(after: .cooldownElapsed, current: anchor, now: now) == now + ) + #expect( + UsageLimitEditPolicy.anchor(after: .grace, current: anchor, now: now) == anchor + ) + #expect( + UsageLimitEditPolicy.anchor(after: .tightening, current: anchor, now: now) == anchor + ) + } + + @Test func tighteningNeverStartsAClockOnItsOwn() { + #expect( + UsageLimitEditPolicy.anchor(after: .tightening, current: nil, now: anchor) == nil + ) + } + + // MARK: - Lock state shown before any edit is proposed + + @Test func lockStateIsUnlockedWithoutHistory() { + #expect(UsageLimitEditPolicy.lockState(anchor: nil, now: anchor) == .unlocked) + } + + @Test func lockStateReportsGraceThenLockThenUnlocked() { + #expect( + UsageLimitEditPolicy.lockState(anchor: anchor, now: anchor + .minutes(1)) + == .grace(until: anchor + .minutes(10)) + ) + #expect( + UsageLimitEditPolicy.lockState(anchor: anchor, now: anchor + .days(2)) + == .locked(until: anchor + .days(7)) + ) + #expect( + UsageLimitEditPolicy.lockState(anchor: anchor, now: anchor + .days(7)) + == .unlocked + ) + } + + @Test func nothingToRefreshWithoutHistory() { + #expect(UsageLimitEditPolicy.refreshDates(anchor: nil).isEmpty) + } + + @Test func refreshesWhenGraceEndsWhenTheCountdownStartsAndWhenTheLockLifts() { + #expect( + UsageLimitEditPolicy.refreshDates(anchor: anchor) == [ + anchor + .minutes(10), + anchor + .days(7) - .minutes(10), + anchor + .days(7), + ] + ) + } +} + +@MainActor +struct UsageLimitModelTests { + @Test func aLimitFlattensToItsOwnSelectionAndAllowance() throws { + let own = FamilyActivitySelection() + let limit = UsageLimit(selection: own, minutesPerDay: 45) + + let dto = try #require(limit.toDTO()) + + #expect(dto.id == limit.id) + #expect(dto.minutesPerDay == 45) + #expect(dto.selectionData == (try own.toData())) + } + + @Test func limitsKeepTheirOrder() { + let limits = [ + UsageLimit(minutesPerDay: 20, sortIndex: 0), + UsageLimit(minutesPerDay: 30, sortIndex: 1), + ] + + #expect(limits.map(\.minutesPerDay) == [20, 30]) + } + + private let created = Date(timeIntervalSinceReferenceDate: 2_000_000) + + @Test func aNewLimitCanBeAdjustedForTenMinutes() { + let limit = UsageLimit(minutesPerDay: 30, createdAt: created) + + #expect(limit.lockState(now: created) == .grace(until: created + .minutes(10))) + #expect( + limit.decide(.adjust(from: 30, to: 60, dropsCoverage: false), now: created + .minutes(9)) + == .allowed(.grace) + ) + #expect(limit.decide(.delete(minutes: 30), now: created + .minutes(9)) == .allowed(.grace)) + } + + @Test func aNewLimitLocksForSevenDaysFromCreation() { + let limit = UsageLimit(minutesPerDay: 30, createdAt: created) + + #expect( + limit.decide(.adjust(from: 30, to: 60, dropsCoverage: false), now: created + .minutes(10)) + == .locked(until: created + .days(7)) + ) + #expect(limit.decide(.delete(minutes: 30), now: created + .days(3)) == .locked(until: created + .days(7))) + #expect( + limit.decide(.adjust(from: 30, to: 20, dropsCoverage: false), now: created + .days(3)) + == .allowed(.tightening) + ) + } + + @Test func limitsKeepSeparatePeriods() { + let older = UsageLimit(minutesPerDay: 30, createdAt: created) + let newer = UsageLimit(minutesPerDay: 30, createdAt: created + .days(3)) + let now = created + .days(3) + .minutes(1) + + #expect(older.lockState(now: now) == .locked(until: created + .days(7))) + #expect(newer.lockState(now: now) == .grace(until: created + .days(3) + .minutes(10))) + } + + @Test func aLooseningAfterThePeriodStartsANewOne() { + let limit = UsageLimit(minutesPerDay: 30, createdAt: created) + let later = created + .days(8) + + limit.commit(.cooldownElapsed, now: later) + + #expect(limit.lockAnchor == later) + #expect(limit.lockState(now: later) == .grace(until: later + .minutes(10))) + } + + @Test func changesInsideGraceDoNotExtendIt() { + let limit = UsageLimit(minutesPerDay: 30, createdAt: created) + + limit.commit(.grace, now: created + .minutes(5)) + limit.commit(.tightening, now: created + .minutes(6)) + + #expect(limit.lockAnchor == created) + } +} diff --git a/NormalTests/Services/BlockActionsTests.swift b/NormalTests/Services/BlockActionsTests.swift index ff80bbb..35ae5ad 100644 --- a/NormalTests/Services/BlockActionsTests.swift +++ b/NormalTests/Services/BlockActionsTests.swift @@ -122,6 +122,102 @@ struct BlockActionsTests { #expect(stopped.contains(SharedConstants.groupTimedUnblockActivityName(for: group.id))) } + private func timedUnblockRecord(id: String, activityName: String, endingIn interval: TimeInterval) throws -> TimedUnblockDTO { + try TimedUnblockDTO( + id: id, + selectionData: FamilyActivitySelection().toData(), + endDate: .now.addingTimeInterval(interval), + activityName: activityName, + isGroupUnblock: id != TimedUnblockService.mainID + ) + } + + private func emergencyUnblock( + schedules: [BlockSchedule] = [], + timedUnblock: TimedUnblockService? = nil, + usageLimit: UsageLimitService? = nil + ) { + BlockActions.emergencyUnblock( + schedules: schedules, + screenTimeService: screenTime, + timedUnblockService: timedUnblock ?? makeTimedUnblock(), + scheduleService: makeSchedule(), + usageLimitService: usageLimit + ?? UsageLimitService(activityCenter: activity, sharedStore: store, ledger: InMemoryUsageLimitLedger(), limitShield: InMemoryDailyLimitShield()) + ) + } + + @Test func emergencyUnblockClearsEveryRestrictionOutright() { + emergencyUnblock() + + #expect(screenTime.removeAllRestrictionsCalled) + #expect(screenTime.disablePreventAppDeleteCalled) + } + + @Test func emergencyUnblockDiscardsEveryTimedUnblockIncludingUnloadedOnes() throws { + let groupID = UUID() + let timedUnblock = makeTimedUnblock() + store.timedUnblocks = [ + try timedUnblockRecord(id: TimedUnblockService.mainID, activityName: SharedConstants.mainTimedUnblockActivityName, endingIn: -60), + try timedUnblockRecord( + id: groupID.uuidString, + activityName: SharedConstants.groupTimedUnblockActivityName(for: groupID), + endingIn: 600 + ), + ] + + emergencyUnblock(timedUnblock: timedUnblock) + + #expect(store.timedUnblocks.isEmpty) + let stopped = Set(activity.stopCalls.flatMap { $0 }.map(\.rawValue)) + #expect(stopped.contains(SharedConstants.mainTimedUnblockActivityName)) + #expect(stopped.contains(SharedConstants.groupTimedUnblockActivityName(for: groupID))) + + let foreground = FakeScreenTimeService() + timedUnblock.reconcile(screenTimeService: foreground) + #expect(foreground.applyShieldOnAllCalled == false) + #expect(foreground.addToShieldsCalled == false) + } + + @Test func emergencyUnblockDisablesSchedulesAndClearsTheOverride() { + let schedule = BlockSchedule( + name: "Work", + selection: FamilyActivitySelection(), + startHour: 9, startMinute: 0, + durationMinutes: 60, + weekdays: [2, 3, 4, 5, 6], + shouldBlock: true, + isTimed: true, + isEnabled: true + ) + store.scheduleOverrideActive = true + + emergencyUnblock(schedules: [schedule]) + + #expect(schedule.isEnabled == false) + #expect(store.scheduleOverrideActive == false) + } + + @Test func emergencyUnblockLiftsSpentMaxDailyLimits() { + let shield = InMemoryDailyLimitShield() + let usageLimit = UsageLimitService( + activityCenter: activity, + sharedStore: store, + ledger: InMemoryUsageLimitLedger(), + limitShield: shield + ) + let limit = UsageLimit(minutesPerDay: 30) + usageLimit.registerAll([limit], config: UsageLimitConfig()) + store.recordUsageState(.reached, for: limit.id) + usageLimit.registerAll([limit], config: UsageLimitConfig()) + #expect(shield.shieldedLimitIDs == [limit.id]) + + emergencyUnblock(usageLimit: usageLimit) + + #expect(shield.shieldedLimitIDs.isEmpty) + #expect(usageLimit.isDayOverridden()) + } + // MARK: - validate @Test func validatePassesWithGlobalKey() throws { diff --git a/NormalTests/Services/UsageLimitServiceTests.swift b/NormalTests/Services/UsageLimitServiceTests.swift new file mode 100644 index 0000000..608c468 --- /dev/null +++ b/NormalTests/Services/UsageLimitServiceTests.swift @@ -0,0 +1,414 @@ +import DeviceActivity +import FamilyControls +import Foundation +@testable import Normal +import Testing + +@MainActor +struct UsageLimitServiceTests { + /// Every test pins its own instant. The production APIs all accept an + /// injected date, so nothing here should consult the wall clock. + private let day = Date(timeIntervalSinceReferenceDate: 800_000_000) + private let config = UsageLimitConfig() + + private struct Harness { + let service: UsageLimitService + let center: FakeDeviceActivityCenter + let store: FakeSharedStore + let ledger: InMemoryUsageLimitLedger + let shield: InMemoryDailyLimitShield + } + + private func makeService(ledgerDate: Date? = nil) -> Harness { + let center = FakeDeviceActivityCenter() + let store = FakeSharedStore() + let ledger = InMemoryUsageLimitLedger(date: ledgerDate) + let shield = InMemoryDailyLimitShield() + let service = UsageLimitService( + activityCenter: center, + sharedStore: store, + ledger: ledger, + limitShield: shield + ) + return Harness(service: service, center: center, store: store, ledger: ledger, shield: shield) + } + + /// `FamilyActivitySelection` cannot be populated with real tokens in a unit + /// test, so DTOs are built directly where a non-empty selection matters. + private func dto(id: UUID = UUID(), minutes: Int) throws -> UsageLimitDTO { + UsageLimitDTO( + id: id, + selectionData: try FamilyActivitySelection().toData(), + minutesPerDay: minutes + ) + } + + private var usageActivity: DeviceActivityName { + DeviceActivityName(SharedConstants.usageLimitsActivityName) + } + + // MARK: - Event specs + + @Test func eventSpecsAreDerivedOnePerLimit() throws { + let first = try dto(minutes: 60) + let second = try dto(minutes: 20) + + let specs = UsageLimitService.eventSpecs(for: [first, second]) + + #expect(specs.count == 2) + #expect(specs.map(\.minutes).sorted() == [20, 60]) + } + + @Test func eventNamesRoundTripBackToTheirLimit() throws { + let limit = try dto(minutes: 60) + + let spec = try #require(UsageLimitService.eventSpecs(for: [limit]).first) + + #expect(spec.eventName == SharedConstants.usageLimitEventName(for: limit.id)) + #expect(SharedConstants.usageLimitID(fromEventName: spec.eventName) == limit.id) + } + + /// A selection with no tokens has nothing to meter, so it must not be + /// armed — otherwise the threshold never fires and the limit looks active + /// while doing nothing. + @Test func aLimitWithAnEmptySelectionIsNotArmable() throws { + let spec = try #require(UsageLimitService.eventSpecs(for: [try dto(minutes: 60)]).first) + + #expect(spec.isArmable == false) + } + + @Test func aLimitWithUndecodableSelectionIsDropped() { + let broken = UsageLimitDTO( + id: UUID(), + selectionData: Data([0xFF, 0xFE]), + minutesPerDay: 60 + ) + + #expect(UsageLimitService.eventSpecs(for: [broken]).isEmpty) + } + + // MARK: - The daily schedule + + @Test func aMidnightResetSpansOneCalendarDay() { + let schedule = DeviceActivityScheduleFactory.daily( + resetMinutes: 0, + warningMinutes: UsageLimitService.warningMinutes + ) + + #expect(schedule.intervalStart.hour == 0) + #expect(schedule.intervalStart.minute == 0) + #expect(schedule.intervalEnd.hour == 23) + #expect(schedule.intervalEnd.minute == 59) + #expect(schedule.intervalEnd.second == 59) + #expect(schedule.repeats) + #expect(schedule.warningTime?.minute == UsageLimitService.warningMinutes) + } + + @Test func aLaterResetWrapsPastMidnight() { + let schedule = DeviceActivityScheduleFactory.daily(resetMinutes: 4 * 60 + 30, warningMinutes: 3) + + #expect(schedule.intervalStart.hour == 4) + #expect(schedule.intervalStart.minute == 30) + #expect(schedule.intervalEnd.hour == 4) + #expect(schedule.intervalEnd.minute == 29) + #expect(schedule.intervalEnd.second == 59) + #expect(schedule.repeats) + } + + // MARK: - Registration + + @Test func registeringWithNoLimitsStopsTheUsageActivityAndStartsNothing() { + let h = makeService() + + h.service.registerAll([], config: config, on: day) + + #expect(h.store.usageLimits.isEmpty) + #expect(h.center.startCalls.isEmpty) + #expect(h.center.stopCalls == [[usageActivity]]) + } + + @Test func registeringMirrorsLimitsAndConfigToTheAppGroup() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 90) + let config = UsageLimitConfig(resetMinutes: 240, preventsAppDelete: true) + + h.service.registerAll([limit], config: config, on: day) + + #expect(h.store.usageLimits.count == 1) + #expect(h.store.usageLimits.first?.minutesPerDay == 90) + #expect(h.store.usageLimits.first?.id == limit.id) + #expect(h.store.usageLimitConfig == config) + } + + // MARK: - Registration is idempotent + + /// `registerAll` runs on every foreground. Tearing monitoring down and + /// re-arming it each time risks disturbing threshold accumulation, so an + /// unchanged registration must be skipped entirely. + @Test func reRegisteringAnUnchangedSetDoesNotTouchMonitoring() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 60) + + h.service.registerAll([limit], config: config, on: day) + let stopsAfterFirst = h.center.stopCalls.count + + h.service.registerAll([limit], config: config, on: day) + + #expect(h.center.stopCalls.count == stopsAfterFirst) + } + + @Test func changingALimitReRegisters() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 60) + + h.service.registerAll([limit], config: config, on: day) + let stopsAfterFirst = h.center.stopCalls.count + + limit.minutesPerDay = 30 + h.service.registerAll([limit], config: config, on: day) + + #expect(h.center.stopCalls.count > stopsAfterFirst) + } + + @Test func changingTheResetTimeReRegisters() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 60) + + h.service.registerAll([limit], config: config, on: day) + let stopsAfterFirst = h.center.stopCalls.count + + h.service.registerAll([limit], config: UsageLimitConfig(resetMinutes: 240), on: day) + + #expect(h.center.stopCalls.count > stopsAfterFirst) + } + + @Test func aNewDayReRegisters() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 60) + + h.service.registerAll([limit], config: config, on: day) + let stopsAfterFirst = h.center.stopCalls.count + + h.service.registerAll([limit], config: config, on: day + .days(1)) + + #expect(h.center.stopCalls.count > stopsAfterFirst) + } + + /// Re-registering must never hand back an allowance already spent. + @Test func reRegisteringDoesNotRefillASpentAllowance() { + let h = makeService() + let total = UsageLimit(minutesPerDay: 30) + h.service.registerAll([total], config: config, on: day) + h.store.recordUsageState(.reached, for: total.id, on: day) + + h.service.registerAll([total], config: config, on: day) + h.service.registerAll([total], config: config, on: day + .hours(2)) + + #expect(h.service.state(for: total, on: day + .hours(2)) == .reached) + } + + @Test func aSpentLimitIsShieldedInTheLimitStore() { + let h = makeService() + let spent = UsageLimit(minutesPerDay: 30) + let other = UsageLimit(minutesPerDay: 60) + h.service.registerAll([spent, other], config: config, on: day) + h.store.recordUsageState(.reached, for: spent.id, on: day) + + h.service.registerAll([spent, other], config: config, on: day) + + #expect(h.shield.shieldedLimitIDs == [spent.id]) + } + + @Test func theLimitStoreLiftsOnceThePeriodResets() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + h.service.registerAll([limit], config: config, on: day) + h.store.recordUsageState(.reached, for: limit.id, on: day) + + h.service.registerAll([limit], config: config, on: day + .days(1)) + + #expect(h.shield.shieldedLimitIDs.isEmpty) + } + + @Test func deletingASpentLimitLiftsItsShield() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + h.service.registerAll([limit], config: config, on: day) + h.store.recordUsageState(.reached, for: limit.id, on: day) + h.service.registerAll([limit], config: config, on: day) + + h.service.registerAll([], config: config, on: day) + + #expect(h.shield.shieldedLimitIDs.isEmpty) + } + + @Test func aSpentLimitKeepsTheAppUndeletableWhenTheSettingIsOn() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + let config = UsageLimitConfig(preventsAppDelete: true) + h.service.registerAll([limit], config: config, on: day) + #expect(h.shield.preventsAppDelete == false) + + h.store.recordUsageState(.reached, for: limit.id, on: day) + h.service.registerAll([limit], config: config, on: day) + + #expect(h.shield.preventsAppDelete) + } + + @Test func aSpentLimitLeavesTheAppDeletableWhenTheSettingIsOff() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + h.service.registerAll([limit], config: UsageLimitConfig(preventsAppDelete: false), on: day) + h.store.recordUsageState(.reached, for: limit.id, on: day) + + h.service.registerAll([limit], config: UsageLimitConfig(preventsAppDelete: false), on: day) + + #expect(h.shield.preventsAppDelete == false) + } + + @Test func aSpentLimitStaysSpentUntilTheConfiguredReset() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + let config = UsageLimitConfig(resetMinutes: 4 * 60) + h.service.registerAll([limit], config: config, on: day) + h.store.recordUsageState(.reached, for: limit.id, on: day) + + let reset = config.period.nextReset(after: day) + + #expect(h.service.nextReset(after: day) == reset) + #expect(h.service.state(for: limit, on: reset - .minutes(1)) == .reached) + #expect(h.service.state(for: limit, on: reset + .minutes(1)) == .under) + } + + @Test func stateDefaultsToUnderAndFollowsWhatTheMonitorWrote() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + + #expect(h.service.state(for: limit, on: day) == .under) + + h.store.recordUsageState(.warning, for: limit.id, on: day) + #expect(h.service.state(for: limit, on: day) == .warning) + + h.store.recordUsageState(.reached, for: limit.id, on: day) + #expect(h.service.state(for: limit, on: day) == .reached) + } + + @Test func statesForAListMatchTheSingleLookups() { + let h = makeService() + let spent = UsageLimit(minutesPerDay: 30) + let fresh = UsageLimit(minutesPerDay: 60) + h.store.recordUsageState(.reached, for: spent.id, on: day) + + let states = h.service.states(for: [spent, fresh], on: day) + + #expect(states[spent.id] == .reached) + #expect(states[fresh.id] == .under) + } + + @Test func aLateWarningCannotUndoAReachedLimit() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + + h.store.recordUsageState(.reached, for: limit.id, on: day) + h.store.recordUsageState(.warning, for: limit.id, on: day) + + #expect(h.service.state(for: limit, on: day) == .reached) + } + + @Test func yesterdaysRecordDoesNotLeakIntoToday() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + let yesterday = day - .days(1) + + h.store.recordUsageState(.reached, for: limit.id, on: yesterday) + + #expect(h.service.state(for: limit, on: yesterday) == .reached) + #expect(h.service.state(for: limit, on: day) == .under) + } + + @Test func clearStateDropsTheRecordAndLiftsOnlyThatLimit() { + let h = makeService() + let raised = UsageLimit(minutesPerDay: 30) + let other = UsageLimit(minutesPerDay: 30) + h.service.registerAll([raised, other], config: config, on: day) + h.store.recordUsageState(.reached, for: raised.id, on: day) + h.store.recordUsageState(.reached, for: other.id, on: day) + + h.service.clearState(for: raised, on: day) + + #expect(h.service.state(for: raised, on: day) == .under) + #expect(h.shield.shieldedLimitIDs == [other.id]) + } + + // MARK: - Emergency override + + @Test func emergencyOverrideLiftsEveryLimitForThePeriod() { + let h = makeService() + let total = UsageLimit(minutesPerDay: 30) + h.service.registerAll([total], config: config, on: day) + h.store.recordUsageState(.reached, for: total.id, on: day) + h.service.registerAll([total], config: config, on: day) + + h.service.overrideToday(on: day) + + #expect(h.service.state(for: total, on: day) == .under) + #expect(h.service.isDayOverridden(on: day)) + #expect(h.shield.shieldedLimitIDs.isEmpty) + } + + @Test func emergencyOverrideKeepsMonitoringArmed() { + let h = makeService() + let total = UsageLimit(minutesPerDay: 30) + h.service.registerAll([total], config: config, on: day) + let stopsBefore = h.center.stopCalls.count + + h.service.overrideToday(on: day) + h.service.registerAll([total], config: config, on: day) + + #expect(h.center.stopCalls.count == stopsBefore) + } + + @Test func theOverrideExpiresWithThePeriod() { + let h = makeService() + h.store.overrideUsageDay(on: day) + + #expect(h.service.isDayOverridden(on: day)) + #expect(h.service.isDayOverridden(on: day + .days(2)) == false) + } + + @Test func theResetAnchorIsReadFromTheLedgerOnLaunch() { + let stored = Date(timeIntervalSinceReferenceDate: 500_000) + let h = makeService(ledgerDate: stored) + + #expect(h.service.resetAnchor == stored) + #expect(h.service.resetLockState(now: stored + .days(1)) == .locked(until: stored + .days(7))) + } + + @Test func committingAResetChangeWritesThroughToTheLedger() { + let h = makeService() + let now = Date(timeIntervalSinceReferenceDate: 700_000) + + h.service.commitResetChange(.cooldownElapsed, now: now) + + #expect(h.ledger.loadLastLoosened() == now) + #expect(h.service.resetAnchor == now) + } + + @Test func aResetChangeInsideGraceLeavesTheLedgerUntouched() { + let stored = Date(timeIntervalSinceReferenceDate: 500_000) + let h = makeService(ledgerDate: stored) + + h.service.commitResetChange(.grace, now: stored + .minutes(5)) + + #expect(h.ledger.loadLastLoosened() == stored) + } + + @Test func resetChangesFollowTheStoredAnchor() { + let stored = Date(timeIntervalSinceReferenceDate: 500_000) + let h = makeService(ledgerDate: stored) + + #expect(h.service.decideResetChange(now: stored + .minutes(5)) == .allowed(.grace)) + #expect(h.service.decideResetChange(now: stored + .days(2)) == .locked(until: stored + .days(7))) + #expect(h.service.decideResetChange(now: stored + .days(7)) == .allowed(.cooldownElapsed)) + } +} diff --git a/NormalTests/Shared/DurationFormatTests.swift b/NormalTests/Shared/DurationFormatTests.swift new file mode 100644 index 0000000..3b08f87 --- /dev/null +++ b/NormalTests/Shared/DurationFormatTests.swift @@ -0,0 +1,24 @@ +@testable import Normal +import Testing + +struct DurationFormatTests { + @Test func compactDropsTheEmptyComponent() { + #expect(DurationFormat.compact(minutes: 0) == "0m") + #expect(DurationFormat.compact(minutes: 45) == "45m") + #expect(DurationFormat.compact(minutes: 60) == "1h") + #expect(DurationFormat.compact(minutes: 90) == "1h 30m") + #expect(DurationFormat.compact(minutes: 120) == "2h") + } + + @Test func compactHandlesTheLongestAllowedLimit() { + #expect(DurationFormat.compact(minutes: UsageLimit.maximumMinutes) == "23h 55m") + } + + @Test func spelledUsesWordsAndPluralisesThem() { + #expect(DurationFormat.spelled(minutes: 1).contains("1")) + #expect(DurationFormat.spelled(minutes: 90).contains("hour")) + #expect(DurationFormat.spelled(minutes: 45).contains("minute")) + // Zero-valued components are hidden rather than printed as "0 hours". + #expect(DurationFormat.spelled(minutes: 60).contains("minute") == false) + } +} diff --git a/NormalTests/Shared/SharedConstantsTests.swift b/NormalTests/Shared/SharedConstantsTests.swift index 93ff296..97f498a 100644 --- a/NormalTests/Shared/SharedConstantsTests.swift +++ b/NormalTests/Shared/SharedConstantsTests.swift @@ -23,6 +23,32 @@ struct SharedConstantsTests { #expect(name.contains(id.uuidString)) } + /// Renaming these orphans activities and defaults blobs already written on + /// device, so they are pinned like the rest. + @Test func usageLimitsActivityNameIsStable() { + #expect(SharedConstants.usageLimitsActivityName == "usageLimits_daily") + } + + @Test func usageDefaultsKeysAreStable() { + #expect(SharedConstants.DefaultsKey.usageLimits == "usageLimits_v1") + #expect(SharedConstants.DefaultsKey.usageDayState == "usageDayState_v1") + #expect(SharedConstants.DefaultsKey.usageRegistration == "usageRegistration_v1") + #expect(SharedConstants.DefaultsKey.usageLimitsIntervalStart == "usageLimitsIntervalStart_v1") + #expect(SharedConstants.DefaultsKey.usageLimitConfig == "usageLimitConfig_v1") + } + + @Test func theWarningFiresInsideTheShortestAllowedLimit() { + #expect(SharedConstants.usageLimitWarningMinutes < UsageLimit.minimumMinutes) + } + + @Test func usageLimitEventNamesRoundTrip() { + let id = UUID() + + #expect(SharedConstants.usageLimitID(fromEventName: SharedConstants.usageLimitEventName(for: id)) == id) + #expect(SharedConstants.usageLimitID(fromEventName: "schedule_\(id.uuidString)") == nil) + #expect(SharedConstants.usageLimitID(fromEventName: "usage_not-a-uuid") == nil) + } + @Test func scheduleActivityNameIsPrefixed() { let id = UUID() let name = SharedConstants.scheduleActivityName(for: id) diff --git a/NormalTests/Shared/UsageDTOTests.swift b/NormalTests/Shared/UsageDTOTests.swift new file mode 100644 index 0000000..f857592 --- /dev/null +++ b/NormalTests/Shared/UsageDTOTests.swift @@ -0,0 +1,301 @@ +import Foundation +@testable import Normal +import Testing + +struct UsageDTOTests { + private func calendar(_ zone: String) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: zone)! + return calendar + } + + private var newYork: Calendar { calendar("America/New_York") } + private var nyPeriod: UsagePeriod { UsagePeriod(calendar: newYork) } + + private func newYorkPeriod(resetAt hour: Int, _ minute: Int = 0) -> UsagePeriod { + UsagePeriod(resetMinutes: hour * 60 + minute, calendar: newYork) + } + + private func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso)! + } + + private let id = UUID() + + // MARK: - Day identity + + @Test func dayKeyIsStableAcrossTheSameLocalDay() { + let morning = nyPeriod.key(for: date("2026-03-14T00:00:01-04:00")) + let night = nyPeriod.key(for: date("2026-03-14T23:59:59-04:00")) + + #expect(morning == "2026-03-14") + #expect(morning == night) + } + + @Test func dayKeyRollsOverAtLocalMidnightNotUTC() { + let before = nyPeriod.key(for: date("2026-03-14T23:00:00-04:00")) + let after = nyPeriod.key(for: date("2026-03-15T01:00:00-04:00")) + + #expect(before == "2026-03-14") + #expect(after == "2026-03-15") + } + + @Test func nextResetIsTheStartOfTheFollowingLocalDay() { + let reset = nyPeriod.nextReset(after: date("2026-06-01T15:30:00-04:00")) + + #expect(nyPeriod.key(for: reset) == "2026-06-02") + #expect(reset == newYork.startOfDay(for: reset)) + } + + /// Santiago springs forward at midnight, so 00:00 does not exist that day. + /// Matching on midnight components would miss it; start-of-day arithmetic + /// resolves to the real first instant. + @Test func nextResetSurvivesAZoneWhoseMidnightIsSkipped() { + let santiago = calendar("America/Santiago") + let beforeTransition = date("2026-09-05T20:00:00-04:00") + + let reset = UsagePeriod(calendar: santiago).nextReset(after: beforeTransition) + + #expect(reset > beforeTransition) + #expect(UsagePeriod(calendar: santiago).key(for: reset) == "2026-09-06") + } + + // MARK: - Day state + + @Test func unknownLimitsReadAsUnder() { + let state = UsageDayStateDTO.fresh(on: date("2026-03-14T10:00:00-04:00"), period: nyPeriod) + + #expect(state.state(for: UUID(), on: date("2026-03-14T11:00:00-04:00"), period: nyPeriod) == .under) + } + + @Test func recordedStateReadsBackWithinTheSameDay() { + let noon = date("2026-03-14T12:00:00-04:00") + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + + #expect(state.state(for: id, on: noon + .hours(6), period: nyPeriod) == .reached) + } + + @Test func statesOnlyEverAdvanceWithinADay() { + let noon = date("2026-03-14T12:00:00-04:00") + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + + #expect(state.recording(.warning, for: id, on: noon, period: nyPeriod).state(for: id, on: noon, period: nyPeriod) == .reached) + #expect(state.recording(.under, for: id, on: noon, period: nyPeriod).state(for: id, on: noon, period: nyPeriod) == .reached) + } + + @Test func rankOrdersStatesBySeverity() { + #expect(UsageLimitState.under < .warning) + #expect(UsageLimitState.warning < .reached) + #expect([UsageLimitState.under, .reached, .warning].max() == .reached) + } + + @Test func aRecordExpiresOnceTheNextDayHasActuallyArrived() { + let noon = date("2026-03-14T12:00:00-04:00") + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + + #expect(state.isStale(on: noon + .hours(6), period: nyPeriod) == false) + #expect(state.isStale(on: date("2026-03-15T09:00:00-04:00"), period: nyPeriod)) + #expect(state.state(for: id, on: date("2026-03-15T09:00:00-04:00"), period: nyPeriod) == .under) + } + + /// The commitment surface must not be refundable by changing the device + /// clock: the calendar day differs, but the real reset instant has not + /// passed, so the spent allowance stands. + @Test func windingTheClockBackDoesNotRefundTheDay() { + let noon = date("2026-03-14T12:00:00-04:00") + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + + let rolledBack = date("2026-03-13T12:00:00-04:00") + + #expect(state.isStale(on: rolledBack, period: nyPeriod) == false) + #expect(state.state(for: id, on: rolledBack, period: nyPeriod) == .reached) + } + + @Test func recordingAgainstAnExpiredDayStartsAFreshRecord() { + let noon = date("2026-03-14T12:00:00-04:00") + let other = UUID() + let nextDay = date("2026-03-15T09:00:00-04:00") + + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + .recording(.reached, for: other, on: nextDay, period: nyPeriod) + + #expect(state.state(for: other, on: nextDay, period: nyPeriod) == .reached) + #expect(state.state(for: id, on: nextDay, period: nyPeriod) == .under) + } + + @Test func clearingDropsOneRecordAndLeavesTheRest() { + let noon = date("2026-03-14T12:00:00-04:00") + let other = UUID() + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + .recording(.reached, for: other, on: noon, period: nyPeriod) + + let cleared = state.clearing(id, on: noon, period: nyPeriod) + + #expect(cleared.state(for: id, on: noon, period: nyPeriod) == .under) + #expect(cleared.state(for: other, on: noon, period: nyPeriod) == .reached) + } + + @Test func clearingAgainstAnExpiredDayIsANoOp() { + let noon = date("2026-03-14T12:00:00-04:00") + let nextDay = date("2026-03-15T09:00:00-04:00") + let state = UsageDayStateDTO.fresh(on: noon, period: nyPeriod) + + let cleared = state.clearing(id, on: nextDay, period: nyPeriod) + + #expect(cleared.dayKey == "2026-03-14") + } + + @Test func overridingClearsEveryRecordAndFlagsTheDay() { + let noon = date("2026-03-14T12:00:00-04:00") + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + .overriding(on: noon, period: nyPeriod) + + #expect(state.isOverridden) + #expect(state.state(for: id, on: noon, period: nyPeriod) == .under) + } + + @Test func aFreshStoreIsStaleSoTheFirstIntervalStartInitialisesIt() { + #expect(UsageDayStateDTO.empty.isStale(on: date("2026-03-14T12:00:00-04:00"), period: nyPeriod)) + } + + @Test func aLaterResetKeepsTheEarlyHoursInThePreviousPeriod() { + let period = newYorkPeriod(resetAt: 4) + + #expect(period.key(for: date("2026-03-14T03:00:00-04:00")) == "2026-03-13") + #expect(period.key(for: date("2026-03-14T05:00:00-04:00")) == "2026-03-14") + #expect(period.start(containing: date("2026-03-14T03:00:00-04:00")) == date("2026-03-13T04:00:00-04:00")) + } + + @Test func nextResetIsTheNextOccurrenceOfTheResetTime() { + let period = newYorkPeriod(resetAt: 4) + + #expect(period.nextReset(after: date("2026-06-01T03:00:00-04:00")) == date("2026-06-01T04:00:00-04:00")) + #expect(period.nextReset(after: date("2026-06-01T04:00:00-04:00")) == date("2026-06-02T04:00:00-04:00")) + #expect(period.nextReset(after: date("2026-06-01T05:00:00-04:00")) == date("2026-06-02T04:00:00-04:00")) + } + + @Test func aLateEveningResetRollsTheNextDay() { + let period = newYorkPeriod(resetAt: 23, 30) + + #expect(period.key(for: date("2026-06-01T23:45:00-04:00")) == "2026-06-01") + #expect(period.key(for: date("2026-06-01T12:00:00-04:00")) == "2026-05-31") + #expect(period.nextReset(after: date("2026-06-01T23:45:00-04:00")) == date("2026-06-02T23:30:00-04:00")) + } + + @Test func aResetInsideTheSpringForwardGapResolvesAfterIt() { + let period = newYorkPeriod(resetAt: 2, 30) + let before = date("2026-03-08T01:00:00-05:00") + + let reset = period.nextReset(after: before) + + #expect(reset > before) + #expect(reset <= date("2026-03-08T03:30:00-04:00")) + #expect(period.key(for: reset) == "2026-03-08") + } + + @Test func aRepeatedResetTimeUsesTheFirstOccurrence() { + let period = newYorkPeriod(resetAt: 1, 30) + + let reset = period.nextReset(after: date("2026-11-01T00:00:00-04:00")) + + #expect(reset == date("2026-11-01T01:30:00-04:00")) + } + + @Test func movingTheResetLaterKeepsASpentRecordUntilTheNewReset() { + let noon = date("2026-03-14T12:00:00-04:00") + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .recording(.reached, for: id, on: noon, period: nyPeriod) + let later = newYorkPeriod(resetAt: 4) + + #expect(state.state(for: id, on: date("2026-03-15T01:00:00-04:00"), period: later) == .reached) + #expect(state.state(for: id, on: date("2026-03-15T05:00:00-04:00"), period: later) == .under) + } + + @Test func movingTheResetEarlierCannotRefundASpentRecord() { + let noon = date("2026-03-14T12:00:00-04:00") + let later = newYorkPeriod(resetAt: 4) + let state = UsageDayStateDTO + .fresh(on: noon, period: later) + .recording(.reached, for: id, on: noon, period: later) + + #expect(state.state(for: id, on: date("2026-03-15T01:00:00-04:00"), period: nyPeriod) == .reached) + #expect(state.state(for: id, on: date("2026-03-15T09:00:00-04:00"), period: nyPeriod) == .under) + } + + @Test func aReportClaimingMoreUseThanTimeSinceTheResetIsImplausible() { + #expect(nyPeriod.couldHaveMetered(minutes: 60, by: date("2026-03-14T00:30:00-04:00")) == false) + #expect(nyPeriod.couldHaveMetered(minutes: 60, by: date("2026-03-14T01:00:00-04:00"))) + #expect(nyPeriod.couldHaveMetered(minutes: 5, by: date("2026-03-14T15:00:00-04:00"))) + } + + @Test func plausibilityIsMeasuredFromTheTopOfTheResetHour() { + let period = newYorkPeriod(resetAt: 4, 30) + + #expect(period.couldHaveMetered(minutes: 30, by: date("2026-03-14T04:45:00-04:00"))) + #expect(period.couldHaveMetered(minutes: 60, by: date("2026-03-14T04:45:00-04:00")) == false) + } + + @Test func configSurvivesThePropertyListRoundTrip() throws { + let config = UsageLimitConfig(resetMinutes: 270, preventsAppDelete: true) + + let decoded = try PropertyListDecoder().decode( + UsageLimitConfig.self, + from: PropertyListEncoder().encode(config) + ) + + #expect(decoded == config) + #expect(decoded.period.resetMinutes == 270) + } + + // MARK: - Wire format + + @Test func limitsSurviveThePropertyListRoundTripTheMonitorUses() throws { + let original = UsageLimitDTO( + id: UUID(), + selectionData: Data([1, 2, 3]), + minutesPerDay: 45 + ) + + let encoded = try PropertyListEncoder().encode([original]) + let decoded = try PropertyListDecoder().decode([UsageLimitDTO].self, from: encoded) + + #expect(decoded.count == 1) + #expect(decoded[0].id == original.id) + #expect(decoded[0].minutesPerDay == 45) + #expect(decoded[0].selectionData == original.selectionData) + } + + /// Fields added after v1 must decode from a payload that predates them, + /// or one missing key takes every limit down with it. + @Test func dayStateDecodesWhenLaterFieldsAreAbsent() throws { + let legacy: [String: Any] = ["dayKey": "2026-03-14", "states": [String: String]()] + let data = try PropertyListSerialization.data( + fromPropertyList: legacy, format: .binary, options: 0 + ) + + let decoded = try PropertyListDecoder().decode(UsageDayStateDTO.self, from: data) + + #expect(decoded.dayKey == "2026-03-14") + #expect(decoded.expiresAt == nil) + #expect(decoded.isOverridden == false) + // No stored reset instant means the day boundary alone decides. + #expect(decoded.isStale(on: date("2026-03-15T09:00:00-04:00"), period: nyPeriod)) + } +} diff --git a/NormalTests/TestSupport/FakeDeviceActivityCenter.swift b/NormalTests/TestSupport/FakeDeviceActivityCenter.swift index 069f2ed..4e43f80 100644 --- a/NormalTests/TestSupport/FakeDeviceActivityCenter.swift +++ b/NormalTests/TestSupport/FakeDeviceActivityCenter.swift @@ -5,6 +5,7 @@ final class FakeDeviceActivityCenter: DeviceActivityProviding { struct StartCall { let name: DeviceActivityName let schedule: DeviceActivitySchedule + let events: [DeviceActivityEvent.Name: DeviceActivityEvent] } var startCalls: [StartCall] = [] @@ -14,10 +15,10 @@ final class FakeDeviceActivityCenter: DeviceActivityProviding { func startMonitoring( _ activityName: DeviceActivityName, during schedule: DeviceActivitySchedule, - events _: [DeviceActivityEvent.Name: DeviceActivityEvent] + events: [DeviceActivityEvent.Name: DeviceActivityEvent] ) throws { if let startError { throw startError } - startCalls.append(StartCall(name: activityName, schedule: schedule)) + startCalls.append(StartCall(name: activityName, schedule: schedule, events: events)) } func stopMonitoring(_ activityNames: [DeviceActivityName]) { diff --git a/NormalTests/TestSupport/FakeScreenTimeService.swift b/NormalTests/TestSupport/FakeScreenTimeService.swift index a18cb13..9458c9c 100644 --- a/NormalTests/TestSupport/FakeScreenTimeService.swift +++ b/NormalTests/TestSupport/FakeScreenTimeService.swift @@ -15,6 +15,7 @@ final class FakeScreenTimeService: ScreenTimeProviding { var applyShieldOnAllBlockAllPreventsAppDelete: Bool? var removeShieldOnAllCalled = false var removeShieldOnAllBlockAllPreventsAppDelete: Bool? + var removeAllRestrictionsCalled = false var addToShieldsCalled = false var addToShieldsSelection: FamilyActivitySelection? var addToShieldsCustomDomains: [String]? @@ -71,6 +72,11 @@ final class FakeScreenTimeService: ScreenTimeProviding { if blockAllPreventsAppDelete { disablePreventAppDelete() } } + func removeAllRestrictions() { + removeAllRestrictionsCalled = true + disablePreventAppDelete() + } + func addToShields(selection: FamilyActivitySelection, customDomains: [String] = []) { addToShieldsCalled = true addToShieldsSelection = selection diff --git a/NormalTests/TestSupport/FakeSharedStore.swift b/NormalTests/TestSupport/FakeSharedStore.swift index 63b0331..5bf0d36 100644 --- a/NormalTests/TestSupport/FakeSharedStore.swift +++ b/NormalTests/TestSupport/FakeSharedStore.swift @@ -6,6 +6,9 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { var schedules: [ScheduleDTO] = [] var scheduleOverrideActive = false var customDomainsEnabled = false + var usageLimits: [UsageLimitDTO] = [] + var usageDayState: UsageDayStateDTO = .empty + var usageRegistration: String? func loadTimedUnblocks() -> [TimedUnblockDTO] { timedUnblocks } func saveTimedUnblocks(_ unblocks: [TimedUnblockDTO]) { timedUnblocks = unblocks } @@ -36,4 +39,21 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { func isCustomDomainsEnabled() -> Bool { customDomainsEnabled } func setCustomDomainsEnabled(_ enabled: Bool) { customDomainsEnabled = enabled } + + func saveUsageLimits(_ limits: [UsageLimitDTO]) { usageLimits = limits } + func loadUsageLimits() -> [UsageLimitDTO] { usageLimits } + + func saveUsageDayState(_ state: UsageDayStateDTO) { usageDayState = state } + func loadUsageDayState() -> UsageDayStateDTO { usageDayState } + + func saveUsageRegistration(_ fingerprint: String?) { usageRegistration = fingerprint } + func loadUsageRegistration() -> String? { usageRegistration } + + var usageLimitsIntervalStart: Date? + func saveUsageLimitsIntervalStart(_ date: Date) { usageLimitsIntervalStart = date } + func loadUsageLimitsIntervalStart() -> Date? { usageLimitsIntervalStart } + + var usageLimitConfig = UsageLimitConfig() + func saveUsageLimitConfig(_ config: UsageLimitConfig) { usageLimitConfig = config } + func loadUsageLimitConfig() -> UsageLimitConfig { usageLimitConfig } } diff --git a/NormalTests/TestSupport/StatefulScreenTimeSpy.swift b/NormalTests/TestSupport/StatefulScreenTimeSpy.swift index 64b1e9e..533b240 100644 --- a/NormalTests/TestSupport/StatefulScreenTimeSpy.swift +++ b/NormalTests/TestSupport/StatefulScreenTimeSpy.swift @@ -52,6 +52,12 @@ final class StatefulScreenTimeSpy: ScreenTimeProviding { checkInvariant() } + func removeAllRestrictions() { + shieldActive = false + appDeleteDisabled = false + checkInvariant() + } + func addToShields(selection _: FamilyActivitySelection, customDomains _: [String] = []) { shieldActive = true checkInvariant() diff --git a/NormalUITests/NormalUITests.swift b/NormalUITests/NormalUITests.swift index 523d699..dd60a2f 100644 --- a/NormalUITests/NormalUITests.swift +++ b/NormalUITests/NormalUITests.swift @@ -254,4 +254,32 @@ final class NormalUITests: XCTestCase { require(app.staticTexts["Group Key"], "Group key should appear in the Group Keys viewer") require(app.staticTexts["Test Group"], "Viewer should show the linked group name") } + + func testMaxDailyLimitsEmptyStateAndEditors() { + let app = launch(["-uiTestMode", "-uiTestSkipOnboarding"]) + + let settings = app.buttons["nav.settings"] + require(settings, "Settings button should exist") + settings.tap() + let link = app.buttons["settings.maxDailyLimitsLink"] + require(link, "Settings should show the Max Daily Limits row") + link.tap() + require(app.staticTexts["No Max Daily Limits"], "Empty state should explain limits") + + let guideAdd = app.buttons["usage.emptyAddLimit"] + require(guideAdd, "The guide should offer to add a limit") + guideAdd.tap() + let save = app.buttons["usage.save"] + require(save, "Editor should open") + XCTAssertFalse(save.isEnabled, "A limit can't be saved without apps") + app.buttons["Cancel"].tap() + + app.swipeUp() + let resetRow = app.buttons["usage.resetTime"] + require(resetRow, "Reset time row should be listed") + resetRow.tap() + let resetSave = app.buttons["usage.resetSave"] + require(resetSave, "Reset time page should open") + XCTAssertFalse(resetSave.isEnabled, "An unchanged reset time can't be saved") + } } diff --git a/Shared/DailyLimitEnforcement.swift b/Shared/DailyLimitEnforcement.swift new file mode 100644 index 0000000..f86726d --- /dev/null +++ b/Shared/DailyLimitEnforcement.swift @@ -0,0 +1,27 @@ +import FamilyControls +import Foundation +import ManagedSettings + +extension ManagedSettingsStore.Name { + static let dailyLimits = Self("normal.dailyLimits") +} + +extension ManagedSettingsStore { + func applyDailyLimits(_ reached: [UsageLimitDTO], preventsAppDelete: Bool) { + let selections = reached.compactMap { try? FamilyActivitySelection.fromData($0.selectionData) } + guard !selections.isEmpty else { + clearAllSettings() + return + } + + replaceShields(with: selections.reduce(FamilyActivitySelection()) { $0.union($1) }) + application.denyAppRemoval = preventsAppDelete ? true : nil + } + + func syncDailyLimits(from sharedStore: some SharedStoreProviding, on date: Date = .now) { + applyDailyLimits( + sharedStore.reachedUsageLimits(on: date), + preventsAppDelete: sharedStore.loadUsageLimitConfig().preventsAppDelete + ) + } +} diff --git a/Shared/DataExtension.swift b/Shared/DataExtension.swift index 6d99096..aaa7f91 100644 --- a/Shared/DataExtension.swift +++ b/Shared/DataExtension.swift @@ -5,3 +5,16 @@ extension Data { map { String(format: "%02x", $0) }.joined() } } + +extension Data { + /// Deterministic across launches, unlike `hashValue`, which Swift seeds + /// randomly per process. Used to tell whether a persisted payload changed. + var stableChecksum: String { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in self { + hash ^= UInt64(byte) + hash &*= 0x0000_0100_0000_01B3 + } + return String(hash, radix: 16) + } +} diff --git a/Shared/SharedConstants.swift b/Shared/SharedConstants.swift index 4f4cabe..365a53f 100644 --- a/Shared/SharedConstants.swift +++ b/Shared/SharedConstants.swift @@ -12,6 +12,11 @@ nonisolated enum SharedConstants { static let widgetKeyTypes = "widgetKeyTypes_v1" static let widgetBlockStatuses = "widgetBlockStatuses_v1" static let widgetUnblockDurations = "widgetUnblockDurations_v1" + static let usageLimits = "usageLimits_v1" + static let usageDayState = "usageDayState_v1" + static let usageRegistration = "usageRegistration_v1" + static let usageLimitsIntervalStart = "usageLimitsIntervalStart_v1" + static let usageLimitConfig = "usageLimitConfig_v1" } static let mainTimedUnblockActivityName = "timedUnblock_main" @@ -23,4 +28,19 @@ nonisolated enum SharedConstants { static func scheduleActivityName(for id: UUID) -> String { "schedule_\(id.uuidString)" } + + static let usageLimitsActivityName = "usageLimits_daily" + + static let usageLimitWarningMinutes = 3 + + private static let usageEventPrefix = "usage_" + + static func usageLimitEventName(for id: UUID) -> String { + usageEventPrefix + id.uuidString + } + + static func usageLimitID(fromEventName name: String) -> UUID? { + guard name.hasPrefix(usageEventPrefix) else { return nil } + return UUID(uuidString: String(name.dropFirst(usageEventPrefix.count))) + } } diff --git a/Shared/SharedDTO.swift b/Shared/SharedDTO.swift index 4156542..393793e 100644 --- a/Shared/SharedDTO.swift +++ b/Shared/SharedDTO.swift @@ -98,4 +98,12 @@ extension FamilyActivitySelection { static func fromData(_ data: Data) throws -> FamilyActivitySelection { try PropertyListDecoder().decode(FamilyActivitySelection.self, from: data) } + + func union(_ other: FamilyActivitySelection) -> FamilyActivitySelection { + var merged = self + merged.applicationTokens.formUnion(other.applicationTokens) + merged.webDomainTokens.formUnion(other.webDomainTokens) + merged.categoryTokens.formUnion(other.categoryTokens) + return merged + } } diff --git a/Shared/SharedStore.swift b/Shared/SharedStore.swift index 5c6a415..be52d12 100644 --- a/Shared/SharedStore.swift +++ b/Shared/SharedStore.swift @@ -72,4 +72,57 @@ struct SharedStore: SharedStoreProviding, Sendable { func setCustomDomainsEnabled(_ enabled: Bool) { defaults.set(enabled, forKey: SharedConstants.DefaultsKey.customDomainsEnabled) } + + func saveUsageLimits(_ limits: [UsageLimitDTO]) { + let data = try? PropertyListEncoder().encode(limits) + defaults.set(data, forKey: SharedConstants.DefaultsKey.usageLimits) + } + + func loadUsageLimits() -> [UsageLimitDTO] { + guard let data = defaults.data(forKey: SharedConstants.DefaultsKey.usageLimits) else { + return [] + } + return (try? PropertyListDecoder().decode([UsageLimitDTO].self, from: data)) ?? [] + } + + func saveUsageDayState(_ state: UsageDayStateDTO) { + let data = try? PropertyListEncoder().encode(state) + defaults.set(data, forKey: SharedConstants.DefaultsKey.usageDayState) + } + + func loadUsageDayState() -> UsageDayStateDTO { + guard let data = defaults.data(forKey: SharedConstants.DefaultsKey.usageDayState) else { + return .empty + } + return (try? PropertyListDecoder().decode(UsageDayStateDTO.self, from: data)) ?? .empty + } + + func saveUsageRegistration(_ fingerprint: String?) { + defaults.set(fingerprint, forKey: SharedConstants.DefaultsKey.usageRegistration) + } + + func loadUsageRegistration() -> String? { + defaults.string(forKey: SharedConstants.DefaultsKey.usageRegistration) + } + + func saveUsageLimitsIntervalStart(_ date: Date) { + defaults.set(date.timeIntervalSinceReferenceDate, forKey: SharedConstants.DefaultsKey.usageLimitsIntervalStart) + } + + func loadUsageLimitsIntervalStart() -> Date? { + let value = defaults.double(forKey: SharedConstants.DefaultsKey.usageLimitsIntervalStart) + return value == 0 ? nil : Date(timeIntervalSinceReferenceDate: value) + } + + func saveUsageLimitConfig(_ config: UsageLimitConfig) { + let data = try? PropertyListEncoder().encode(config) + defaults.set(data, forKey: SharedConstants.DefaultsKey.usageLimitConfig) + } + + func loadUsageLimitConfig() -> UsageLimitConfig { + guard let data = defaults.data(forKey: SharedConstants.DefaultsKey.usageLimitConfig) else { + return UsageLimitConfig() + } + return (try? PropertyListDecoder().decode(UsageLimitConfig.self, from: data)) ?? UsageLimitConfig() + } } diff --git a/Shared/SharedStoreProviding.swift b/Shared/SharedStoreProviding.swift index 2ef1537..cfc75d3 100644 --- a/Shared/SharedStoreProviding.swift +++ b/Shared/SharedStoreProviding.swift @@ -13,6 +13,16 @@ nonisolated protocol SharedStoreProviding: Sendable { func setScheduleOverrideActive(_ active: Bool) func isCustomDomainsEnabled() -> Bool func setCustomDomainsEnabled(_ enabled: Bool) + func saveUsageLimits(_ limits: [UsageLimitDTO]) + func loadUsageLimits() -> [UsageLimitDTO] + func saveUsageDayState(_ state: UsageDayStateDTO) + func loadUsageDayState() -> UsageDayStateDTO + func saveUsageRegistration(_ fingerprint: String?) + func loadUsageRegistration() -> String? + func saveUsageLimitsIntervalStart(_ date: Date) + func loadUsageLimitsIntervalStart() -> Date? + func saveUsageLimitConfig(_ config: UsageLimitConfig) + func loadUsageLimitConfig() -> UsageLimitConfig } enum ScheduleStartDecision: Equatable { @@ -30,4 +40,58 @@ extension SharedStoreProviding { if isScheduleOverrideActive() { setScheduleOverrideActive(false) } return .apply } + + var usagePeriod: UsagePeriod { loadUsageLimitConfig().period } + + func usageState(for id: UUID, on date: Date = .now) -> UsageLimitState { + loadUsageDayState().state(for: id, on: date, period: usagePeriod) + } + + func usageStates(for ids: [UUID], on date: Date = .now) -> [UUID: UsageLimitState] { + let dayState = loadUsageDayState() + let period = usagePeriod + return Dictionary( + ids.map { ($0, dayState.state(for: $0, on: date, period: period)) }, + uniquingKeysWith: { first, _ in first } + ) + } + + func recordUsageState(_ state: UsageLimitState, for id: UUID, on date: Date = .now) { + let period = usagePeriod + mutateUsageDayState { $0.recording(state, for: id, on: date, period: period) } + } + + func clearUsageState(for id: UUID, on date: Date = .now) { + let period = usagePeriod + mutateUsageDayState { $0.clearing(id, on: date, period: period) } + } + + func resetUsageDayIfStale(on date: Date = .now) { + let period = usagePeriod + guard loadUsageDayState().isStale(on: date, period: period) else { return } + saveUsageDayState(.fresh(on: date, period: period)) + } + + func overrideUsageDay(on date: Date = .now) { + saveUsageDayState(loadUsageDayState().overriding(on: date, period: usagePeriod)) + } + + func isUsageDayOverridden(on date: Date = .now) -> Bool { + let current = loadUsageDayState() + return !current.isStale(on: date, period: usagePeriod) && current.isOverridden + } + + func reachedUsageLimits(on date: Date = .now) -> [UsageLimitDTO] { + guard !isUsageDayOverridden(on: date) else { return [] } + let limits = loadUsageLimits() + let states = usageStates(for: limits.map(\.id), on: date) + return limits.filter { states[$0.id] == .reached } + } + + private func mutateUsageDayState(_ transform: (UsageDayStateDTO) -> UsageDayStateDTO) { + let current = loadUsageDayState() + let updated = transform(current) + guard updated != current else { return } + saveUsageDayState(updated) + } } diff --git a/Shared/UsageDTO.swift b/Shared/UsageDTO.swift new file mode 100644 index 0000000..95d0b48 --- /dev/null +++ b/Shared/UsageDTO.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Lifecycle of a single daily usage limit within one day. +/// +/// Advanced only by `NormalMonitor` in response to `DeviceActivity` threshold +/// callbacks, and reset when the day rolls over. +nonisolated enum UsageLimitState: String, Codable, Sendable, CaseIterable, Comparable { + case under + case warning + case reached + + /// Severity order. States only ever advance within a day, so a late + /// `warning` callback cannot undo a `reached` one. + private var rank: Int { + switch self { + case .under: 0 + case .warning: 1 + case .reached: 2 + } + } + + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rank < rhs.rank } +} + +/// A daily usage limit, flattened for the monitor extension. +/// +/// Fields added after v1 should follow the `encoded…` convention used by the +/// other shared DTOs — optional storage with a defaulting accessor — so a +/// payload written by an older build still decodes instead of throwing and +/// taking every limit down with it. +nonisolated struct UsageLimitDTO: Codable, Sendable, Identifiable { + let id: UUID + let selectionData: Data + let minutesPerDay: Int + + init(id: UUID, selectionData: Data, minutesPerDay: Int) { + self.id = id + self.selectionData = selectionData + self.minutesPerDay = minutesPerDay + } +} + +nonisolated struct UsageLimitConfig: Codable, Sendable, Equatable { + var resetMinutes: Int + var preventsAppDelete: Bool + + init(resetMinutes: Int = 0, preventsAppDelete: Bool = false) { + self.resetMinutes = resetMinutes + self.preventsAppDelete = preventsAppDelete + } + + var period: UsagePeriod { UsagePeriod(resetMinutes: resetMinutes) } +} + +nonisolated struct UsageDayStateDTO: Codable, Sendable, Equatable { + private(set) var dayKey: String + private(set) var states: [String: UsageLimitState] + + private var encodedExpiresAt: Date? + private var encodedIsOverridden: Bool? + + var expiresAt: Date? { encodedExpiresAt } + + var isOverridden: Bool { encodedIsOverridden ?? false } + + static let empty = UsageDayStateDTO(dayKey: "", states: [:]) + + init( + dayKey: String, + states: [String: UsageLimitState], + expiresAt: Date? = nil, + isOverridden: Bool? = nil + ) { + self.dayKey = dayKey + self.states = states + encodedExpiresAt = expiresAt + encodedIsOverridden = isOverridden + } + + static func fresh(on date: Date, period: UsagePeriod = UsagePeriod()) -> Self { + UsageDayStateDTO( + dayKey: period.key(for: date), + states: [:], + expiresAt: period.nextReset(after: date) + ) + } + + func isStale(on date: Date, period: UsagePeriod = UsagePeriod()) -> Bool { + guard dayKey != period.key(for: date) else { return false } + guard let expiresAt else { return true } + return date >= expiresAt + } + + func state(for id: UUID, on date: Date, period: UsagePeriod = UsagePeriod()) -> UsageLimitState { + guard !isStale(on: date, period: period) else { return .under } + return states[id.uuidString] ?? .under + } + + func recording( + _ state: UsageLimitState, + for id: UUID, + on date: Date, + period: UsagePeriod = UsagePeriod() + ) -> Self { + var updated = isStale(on: date, period: period) ? .fresh(on: date, period: period) : self + guard state > (updated.states[id.uuidString] ?? .under) else { return updated } + updated.states[id.uuidString] = state + return updated + } + + func clearing(_ id: UUID, on date: Date, period: UsagePeriod = UsagePeriod()) -> Self { + guard !isStale(on: date, period: period) else { return self } + var updated = self + updated.states.removeValue(forKey: id.uuidString) + return updated + } + + func overriding(on date: Date, period: UsagePeriod = UsagePeriod()) -> Self { + var updated = Self.fresh(on: date, period: period) + updated.encodedIsOverridden = true + return updated + } +} + +nonisolated struct UsagePeriod: Equatable, Sendable { + let resetMinutes: Int + let calendar: Calendar + + init(resetMinutes: Int = 0, calendar: Calendar = .current) { + self.resetMinutes = min(max(resetMinutes, 0), 24 * 60 - 1) + self.calendar = calendar + } + + func start(containing date: Date) -> Date { + let sameDay = reset(onDayOf: date) + if sameDay <= date { return sameDay } + guard let dayBefore = calendar.date(byAdding: .day, value: -1, to: date) else { return sameDay } + return reset(onDayOf: dayBefore) + } + + func nextReset(after date: Date) -> Date { + let sameDay = reset(onDayOf: date) + if sameDay > date { return sameDay } + guard let dayAfter = calendar.date(byAdding: .day, value: 1, to: date) else { return sameDay } + return reset(onDayOf: dayAfter) + } + + func key(for date: Date) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: start(containing: date)) + return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + } + + func couldHaveMetered(minutes: Int, by date: Date) -> Bool { + let start = start(containing: date) + let hourStart = calendar.dateInterval(of: .hour, for: start)?.start ?? start + return date.timeIntervalSince(hourStart) + 60 >= TimeInterval(minutes) * 60 + } + + private func reset(onDayOf date: Date) -> Date { + calendar.date( + bySettingHour: resetMinutes / 60, + minute: resetMinutes % 60, + second: 0, + of: date, + matchingPolicy: .nextTime, + repeatedTimePolicy: .first, + direction: .forward + ) ?? calendar.startOfDay(for: date) + } +}