Files
OpenAppLock/docs/RULES_FEATURE_SPEC.md
Brendan Chen 05e0ee2755 feat: enforce schedule rules in the background via DeviceActivity windows
Schedule (time-window) rules had no background enforcement: RuleScheduler.sync()
skipped them, so their shields were applied only by RuleEnforcer.refresh() — the
launch + 30s foreground loop. A window that began while the app was closed didn't
engage until the user reopened the app, which is why scheduled blocks could land
late or unevenly.

RuleScheduler now registers a repeating DeviceActivitySchedule per enabled
schedule rule's window (sched-<uuid>, plus sched2-<uuid> for windows that cross
midnight, since DeviceActivity can't express an interval whose end precedes its
start). The monitor extension routes these to a new ScheduleEnforcement.reconcile(),
which recomputes the rule's live schedule state from its snapshot
(RuleSchedule.isActive, honouring days, pause and the midnight-crossing rule) and
applies or clears the shield to match — the same logic RuleEnforcer.refresh runs
in the foreground, kept as the reconciliation safety net because interval callbacks
are known to fire late or not at all.

- RuleSnapshot gains startMinutes/endMinutes, with a tolerant decoder so snapshots
  written before these fields still load instead of failing the whole batch and
  blinding the extensions until the app reopens.
- Window activities are fingerprinted on their interval alone, so changing days,
  mode or apps no longer needlessly restarts monitoring.

On-device verification of the background transition is still pending (the simulator
does not deliver DeviceActivity callbacks); covered by new unit tests across the
scheduler, the snapshot codec and the new enforcement reactions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 02:36:09 -04:00

24 KiB
Raw Blame History

OpenAppLock — "Rules" Feature Spec (derived from Opal screen recording)

Source: ScreenRecording_06-12-2026 11-26-19_1.MP4 (iPhone, 4:29). The rules feature appears roughly between 1:003:00 of the recording, on Opal's "My Apps" tab. This spec describes what was observed, then maps it onto an implementation plan for OpenAppLock.


1. Concept

A Rule is a recurring, automated app-blocking policy. Unlike a one-off block/timer session, a rule re-arms itself on a schedule. Three rule types are offered (Opal's "New Rule" sheet):

Type Icon Example shown Semantics
Schedule calendar grid "e.g. 9-5, Daily" Block selected apps during a daily time window on chosen days
Time Limit hourglass "e.g. 45m/day" After N minutes of cumulative use of selected apps per day, block them until a reset point
Open Limit padlock "e.g. 5 opens/day" After N opens of selected apps per day, block them (not demoed in video; inferred from card)

Common attributes across all types:

  • Name — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
  • Days of week — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom
  • App List (OpenAppLock refinement) — each rule points to exactly one App List: a named, reusable selection of apps/categories/websites stored independently of any rule. When editing a rule the user picks an existing list or creates a new one; editing a list affects every rule that uses it. Deleting a list detaches it from its rules (they fall back to "no apps").
  • Selection modeBlock (block the list) or Allow Only (block everything except the list). The mode belongs to the rule, not the list. Only Schedule rules offer the choice; Time Limit and Open Limit rules are always Block (a usage budget over "everything except X" is not meaningful, and drafts of those kinds are sanitized back to Block).
  • Hard Mode — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
  • Enabled/disabled — a rule can be disabled without deleting ("Disable Rule")

Derived status (drives card/detail UI):

  • Active → countdown to window end: green pill "6h left"
  • Inactive → countdown to next activation: "Starts in 22h" / "Starts in 11h"

2. Screen inventory & navigation map

Tab bar: [Home] [My Apps] [Timer]
                  │
                  ▼
            Apps screen (large title "Apps")
            ├── "Blocked Apps" section
            ├── "Rules >" section  ──ta p "+ New"──▶ New Rule sheet
            │        │                                 ├── tap rule-type card ─▶ Rule Editor (blank/default)
            │        │                                 └── tap preset card ───▶ Rule Editor (pre-filled)
            │        └── tap rule card ─▶ Rule Detail sheet
            │                               └── "Edit Rule" ─▶ Rule Editor (edit mode)
            │                                                    └── "Selected Apps >" ─▶ App Picker
            └── "Apps" section (folders: Distracting / Always Allowed / Never Allowed)

All rules UI is presented as sheets stacked over the Apps screen (dimmed, blurred background; grabber at top; circular ✕ or button top-left). Nothing navigates by push except within the sheet stack.


3. Screens in detail

3.1 Apps screen ("My Apps" tab)

Dark theme throughout (near-black background, very dark green tint).

  1. Large title "Apps".
  2. Blocked Apps — section header; horizontal row of currently-blocked app icons. Each icon has a lock badge overlay and a teal/green rounded-rect outline; caption "Unblock" under the icon. Tapping unblocks (with friction if hard mode — not demoed). (OpenAppLock) Time/Open Limit rules whose budget is spent for the day also appear here, blocked until midnight.
  3. Usage (OpenAppLock addition — not in the Opal video) — a section directly below Blocked Apps showing live tracking for every enabled Time/Open Limit rule scheduled today:
    • Time Limit row: subtitle "18m of 45m used today", trailing "27m left"; when spent: "Blocked until tomorrow" (red).
    • Open Limit row: subtitle "2 of 5 opens today", trailing "3 opens left"; when spent: "Blocked until tomorrow" (red). Usage numbers come from the shared app-group ledger written by the DeviceActivity monitor and shield-action extensions.
  4. Rules — header row: "Rules " (leading, tappable to a full list, presumably) and "+ New" (trailing, green tint) which opens the New Rule sheet.
    • Content: horizontally scrolling row of rule cards (~2 visible).
    • Rule card anatomy (rounded ~24pt corners):
      • Top: icon pair — rule-type icon (calendar) → small arrow → shield icon. Active rule: icons in color, card tinted dark green. Inactive: greyscale.
      • Middle: status — active: green capsule pill "6h left"; inactive: plain text "Starts in 22h".
      • Bottom: rule name (semibold), then a sub-row "Block" + tiny cluster of the blocked app icons.
  5. Apps — section of folder-style groups: "Distracting (4 items)" showing a 2×2 mini icon grid, "Always Allowed", "Never Allowed (Hidden)" with an eye-slash glyph. (Out of scope for the rules clone but shares the app selection model.)

3.2 Rule Detail sheet

Presented on tapping a rule card. Partial-height card sheet.

  • Top-left: circular ✕ close button.
  • Centered: icon pair (rule type → shield), then caption "Schedule, Starts in 22h" (type + live status), then large title ("Weekend Zen").
  • Detail rows (single inset rounded card, label left / value right):
    Label Example value
    During this time 09:00 12:00
    On these days Weekends
    Block [app icons] 1 App / 3 Apps
    Unblocks allowed Yes (hidden/No when Hard Mode)
  • Bottom: full-width white pill button "✎ Edit Rule" → morphs the sheet into the Rule Editor in edit mode.

3.3 New Rule sheet

Presented from "+ New". Full-height sheet, scrollable.

  • Header: ✕ left, centered title "New Rule".
  • Rule type row — 3 horizontally arranged cards (Schedule / Time Limit / Open Limit), each: glyph, bold name, example caption ("e.g. 9-5, Daily", "e.g. 45m/day", "e.g. 5 opens/day"). Tapping opens the matching editor with defaults.
  • Preset gallery — vertically scrolling sections, each with a bold header
    • grey subtitle, containing a 2-up grid of photo-backed preset cards:
    • Get More Done — "Maximize your productivity while staying sane."
      • Work Time (Schedule 09:0017:00, Block, weekdays)
      • Laser Focus (Schedule 14:0018:00, Block)
    • Sleep, Relax and Reset — "Sleep better, rise refreshed."
      • Wind Down (Schedule 20:0022:00, Block)
      • Deep Sleep (Schedule 22:0006:00, Block)
    • Build Healthy Habits — "Spend time on important things."
      • Reading Time (Schedule ~19:4520:45, Block)
      • Gym Time (Schedule 17:3018:30, Block)
    • Preset card anatomy: full-bleed background photo, top row icon pair (type → shield), time range caption, name, "Block" + suggested app icons, and a circular "+" button bottom-right. Tapping anywhere opens the Schedule editor pre-filled with the preset's name/times/days.

Navigation (OpenAppLock): picking a rule type or preset pushes the editor inside the sheet via native SwiftUI navigation (NavigationStack + navigationDestination(item:)), so the system push animation and edge-swipe-back work; the editor keeps its custom header chrome.

3.4 Rule Editor — Schedule type

Sheet with: back (top-left), centered rule name as title, ✎ pencil button (top-right) to rename.

Sections (each an inset rounded group with a small icon + caption header):

  1. 📅 During this time
    • Rows From / To with right-aligned time + stepper chevrons (09:00 ⌃⌄).
    • A dotted vertical line with ●/○ endpoints visually links From → To.
    • Tapping a row expands an inline wheel time picker (24h, demoed changing From 22:00 → 23:00).
  2. On these days: — trailing summary label ("Weekdays"/"Weekends"/custom); row of 7 circular toggles S M T W T F S; selected = filled white circle with black letter, unselected = dark circle.
  3. 🛡 Apps are blocked
    • Row: App List<list name> · N Apps (or Choose when none) — presents the App List picker.
    • A segmented Block | Allow Only row (Schedule editor only) chooses how the rule interprets its list; the section header reads "Apps are blocked" / "Only these apps are allowed" accordingly.
  4. Hard Mode ⚡PRO badge — subtitle "No unblocks allowed"; trailing toggle.
  5. Block Adult Content (OpenAppLock addition — not in the Opal video) — subtitle "Filter adult websites while this rule is active"; trailing toggle. Maps to Screen Time's web-content filter (ManagedSettingsStore.webContent.blockedByFilter = .auto(...)), applied and cleared together with the rule's shield. Surfaces in the rule detail as an "Adult websites | Blocked/Allowed" row.
  6. CTA
    • Creating: full-width gradient pill "Hold to Commit" — a press-and-hold interaction (deliberate friction) that fills, then saves and dismisses to the Apps screen where the new card appears.
    • Editing existing: "✓ Done" pill, plus a red text button "⏸ Disable Rule" beneath it.

3.5 Rule Editor — Time Limit type ("Time Keeper")

Same chrome (back / title / rename). Sections:

  1. When I use — row This AppSelect (app selection).
  2. For this long — subtitle "Daily"; right-aligned value with stepper 45m ⌃⌄.
  3. On these days: — identical day picker as Schedule.
  4. 🛡 Then block app — row Until with stepper value Tomorrow ⌃⌄ (reset point — e.g. tomorrow/next morning).
  5. Hard Mode toggle — same as Schedule.
  6. Block Adult Content toggle — same as Schedule.
  7. Hold to Commit.

3.6 Rule Editor — Open Limit type

Not demoed beyond its card. Spec by analogy: "When I open [apps]" / "More than N opens ⌃⌄ (Daily)" / day picker / "Then block until …" / Hard Mode / Block Adult Content / Hold to Commit.

3.7 App Picker (shared component — also used in onboarding & timer)

Full-height sheet:

  • Header: back, centered title "Selected", and a circular green confirm button top-right.
  • Segmented control: Block | Allow Only.
  • Top rows: "+ Add App or Website", a "Suggested" horizontal row of app icons (one-tap add), and a "Never Allowed — 0 Apps" row with footnote "Never allowed Apps will also be blocked".
  • Hint text: Select apps/websites, tap ">" to expand.
  • Category list — each row: circular checkbox (tri-state: empty / partially-selected count / checked), emoji glyph, category name, trailing selected-count + chevron to expand into individual apps: All Apps & Categories, Social, Games, Entertainment, Creativity, Education, Health & Fitness, Information & Reading, Productivity & Finance, Shopping & Food.
  • Search bar pinned near bottom (with mic). Typing surfaces app matches and website suggestions (e.g. typing "insta" offers instagram.com), letting users add arbitrary domains.
  • Footer: "N Apps Selected" caption + white pill "Save" (+ "Cancel").

Implementation note: Opal ships its own app categorization. On iOS, third parties cannot enumerate installed apps; the system-sanctioned route is FamilyActivityPicker (FamilyControls), which provides its own category/app/website UI and returns opaque tokens. v1 of OpenAppLock should embed FamilyActivityPicker instead of cloning Opal's custom picker.

App Lists (OpenAppLock): the selection itself lives on a reusable App List (@Model AppList: name + encoded FamilyActivitySelection). The editor's App List row presents a picker sheet listing saved lists (checkmark on the rule's current list; tap to select), an Edit affordance per list, and a "New List" flow — a name field plus an embedded FamilyActivityPicker. The Block/Allow Only segmented control lives in the Schedule rule editor (it is rule state, not list state). Legacy rules that stored an inline selection are migrated at launch: one list per distinct selection (rules sharing identical selection data share a list), named " Apps". Lists in use by a rule cannot be deleted from the picker. While any Hard Mode rule is actively blocking, all lists are read-only — the picker hides Edit/Delete and shows a lock notice — because editing a list would be a back door out of the hard block. Creating new lists and selecting lists for other rules remain available.


4. Behavioral spec

  1. Activation — a Schedule rule becomes active at From on an enabled day and deactivates at To (windows crossing midnight, e.g. 22:0006:00, must be supported — Deep Sleep preset does this).
  2. While active — the rule's app selection is shielded (and, when the rule's Block Adult Content toggle is on, Screen Time's adult-website filter is engaged for the same span); blocked apps also surface in the "Blocked Apps" row on the Apps screen; the card turns green with a "Xh left" pill.
  3. Unblocking — with Hard Mode off, the user may unblock mid-window ("Unblocks allowed: Yes"). With Hard Mode on, no unblocks until the window ends.
  4. Time-limit rules — accumulate usage daily across the selected apps; on crossing the threshold, shield until the Until reset point (e.g. tomorrow), then reset the budget. (OpenAppLock specifics): usage lives in a per-rule, per-day usage ledger in the app group. A limit rule's derived status becomes active(until: next midnight) once the ledger reports the budget spent on an enabled day — it then surfaces in Blocked Apps, Hard Mode gating applies, and a soft unblock pauses it until midnight. Open-limit rules work the same with an opens budget; while opens remain, their apps stay shielded with an "Open" button on the shield (each press spends one open and lifts the shield for up to 15 minutes — the DeviceActivity minimum interval).
  5. Disable vs delete — "Disable Rule" pauses scheduling but keeps the rule (card presumably shows disabled state). No delete flow was shown; add delete via swipe/long-press or a button in the editor.
  6. Commit friction — creating/committing a rule uses press-and-hold ("Hold to Commit"), echoing Opal's philosophy that starting a commitment should be deliberate. Editing uses a plain "Done".
  7. Live countdowns — "Starts in 22h" / "6h left" update over time (minute granularity is fine).

5. Implementation plan for OpenAppLock

5.1 Frameworks & capabilities

  • FamilyControlsAuthorizationCenter.shared.requestAuthorization(for: .individual); FamilyActivityPicker + FamilyActivitySelection (app/category/web tokens).
  • ManagedSettingsManagedSettingsStore per rule (ManagedSettingsStore.Name("rule-<uuid>")); set store.shield.applications / applicationCategories / webDomains.
  • DeviceActivityDeviceActivityCenter.startMonitoring with a DeviceActivitySchedule(intervalStart:intervalEnd:repeats:) per rule; a DeviceActivityMonitor app extension applies/removes shields in intervalDidStart/intervalDidEnd. Time-limit rules use DeviceActivityEvent(applications:threshold:) + eventDidReachThreshold.
  • Requires the Family Controls entitlement (works in dev; distribution needs Apple approval) and an App Group to share rule data with the monitor extension.

5.2 Data model (SwiftData)

Replace the template AppListProfile with:

enum RuleKind: String, Codable { case schedule, timeLimit, openLimit }
enum SelectionMode: String, Codable { case block, allowOnly }

@Model final class BlockingRule {
    var id: UUID
    var name: String
    var kind: RuleKind
    var isEnabled: Bool
    var hardMode: Bool
    var blockAdultContent: Bool      // webContent.blockedByFilter = .auto(...)
    var selectionMode: SelectionMode
    var selectionData: Data          // encoded FamilyActivitySelection
    var days: [Int]                  // 1...7, Calendar weekday numbers
    // schedule
    var startMinutes: Int           // minutes from midnight, e.g. 540 = 09:00
    var endMinutes: Int             // may be < start (crosses midnight)
    // time limit
    var dailyLimitMinutes: Int?
    var resetPoint: String?          // "tomorrow" | "nextMorning" …
    // open limit
    var maxOpens: Int?
    var createdAt: Date
}

FamilyActivitySelection is Codable → store as Data. Status ("active", "starts in Xh", "Xh left") is derived, not stored.

5.3 View inventory

View Notes
AppsView (tab) Sections: Blocked Apps, Rules carousel, (later) app folders
RuleCardView Card per §3.1, active/inactive styling
RuleDetailSheet §3.2, rows + Edit Rule
NewRuleSheet §3.3, type cards + preset gallery (RulePreset static data)
ScheduleRuleEditor §3.4
TimeLimitRuleEditor §3.5
DayOfWeekPicker 7 circle toggles + summary ("Weekdays"/"Weekends"/…)
AppSelectionView wraps FamilyActivityPicker, Block/Allow Only segmented control
HoldToCommitButton long-press progress fill, haptics, fires on completion
RuleScheduler (service) translates BlockingRule ⇄ DeviceActivity monitoring
ShieldController (service) applies/clears ManagedSettingsStore shields

5.4 Suggested build order

  1. Data model + Apps tab with Rules section (cards from seeded sample rules, status derivation, detail sheet) — pure UI, no entitlements needed.
  2. New Rule sheet + Schedule editor + day picker + Hold to Commit (CRUD into SwiftData; Disable/Done editing path).
  3. FamilyControls authorization + FamilyActivityPicker integration ("Selected Apps" row, "N Apps" counts, icon clusters via Label(token:)).
  4. DeviceActivity monitor extension + ManagedSettings shields (real blocking, incl. midnight-crossing windows).
  5. Time Limit editor + threshold events; Open Limit last (needs shield action extension + open counting).
  6. Preset gallery content + polish (gradients, photos, haptics, live countdown timers).

5.5 Background enforcement architecture (implemented)

  • App group group.dev.bchen.OpenAppLock shares three stores between the app and its extensions: RuleSnapshotStore (Codable rule mirror, written by RuleScheduler on every enforcement refresh), UsageLedger (per-rule, per-day minutes/opens), and the shield-store tracking list.
  • RuleScheduler (app) reconciles DeviceActivity monitoring with the enabled rules:
    • Limit rules — one repeating 00:0023:59 activity per rule (rule-<uuid>); time-limit rules carry one cumulative usage-threshold event per budget minute (minutes-<k>) over the rule's app list.
    • Schedule rules — one (or, for windows that cross midnight, two) repeating window activit(ies) per rule matching the rule's From…To window (sched-<uuid> and, for the post-midnight half, sched2-<uuid>). These carry no events; they exist purely to wake the monitor at the window edges so shields engage in the background even when the app is closed. A window that ends exactly at midnight, or is shorter than DeviceActivity's 15-minute minimum interval, may fail to register (intervalTooShort) and falls back to the foreground loop. Activities restart only when their configuration changes, because a restart resets threshold accounting.
  • OpenAppLockMonitor (DeviceActivityMonitor extension): interval start = midnight reset for limit rules (open-limit rules re-shield so opens can be counted; time-limit shields clear for the fresh budget); each minutes-<k> event records usage and shields at the budget; a finished open-session-<uuid> one-shot re-shields after a granted open. For schedule-window activities (sched-/sched2-), both interval start and interval end recompute the rule's live schedule state from its snapshot (RuleSchedule.isActive, honouring enabled days, pause and the midnight-crossing rule) and apply or clear the shield accordingly — the same logic RuleEnforcer.refresh runs in the foreground, so the two paths agree.
  • Reliability posture — DeviceActivity interval callbacks are "first device use after the boundary", are known to fire late or be skipped (device asleep, OS regressions on iOS 17/18/26), and a shield written over an app the user already has open may not visibly engage until that app is relaunched (a long-standing Screen Time platform limitation). Background monitoring is therefore best-effort; RuleEnforcer.refresh (launch + 30 s foreground loop) is retained as the reconciliation safety net and is the source of truth whenever the app runs.
  • OpenAppLockShieldConfig (ShieldConfiguration extension): open-limit shields show "Opened X of N times today" with an "Open (Y left)" secondary button; other shields show the blocking rule's name.
  • OpenAppLockShieldAction (ShieldAction extension): the Open press spends one open in the ledger, lifts the rule's shield, and starts the ~15-minute one-shot session (DeviceActivity's minimum interval).
  • All shared logic lives in Shared/ (notably LimitEnforcement), unit tested from the app test target.

5.6 Out of scope (seen in video, not part of "rules")

  • Onboarding flow, paywall ("You know Opal works. Make it permanent."), Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?" friction screen), notification nudges ("Complete Your Setup").

6. Native UI re-skin (current presentation)

OpenAppLock has since replaced the Opal-style custom presentation with the bare iOS design language, keeping the backend (models, logic, services), the flows, and the accessibility identifiers intact. Sections 15 remain as the reference for what the feature does; presentation now maps as follows:

Spec element Native presentation
Apps home NavigationStack + List; "Blocked Apps" and "Rules" sections; rules are list rows (kind icon, name, block summary, trailing live status — green when active); "+" toolbar button
Rule detail Sheet with inline nav title (name + "Schedule, 6h left" caption), LabeledContent rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead
New Rule List with a "Rule Type" section and preset sections as plain rows; editor pushed via navigationDestination(item:)
Rule editor Native Form: an inline Name text field at the top (no separate rename button; empty names fall back to the kind default), DatePicker rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Both modes commit via a checkmark in the navigation bar (labels: "Add Rule" / "Done"; replaces Hold to Commit). In edit mode an ellipsis menu ("Rule Actions") next to the checkmark holds Disable Rule and the destructive Delete Rule
Onboarding / app picker System styling, .borderedProminent buttons, default color scheme (no forced dark, default accent)

Dropped custom components: Theme, HoldToCommitButton, RuleCardView, icon-pair/circle-button chrome.