fix: show a generic "App Blocked" shield instead of mis-attributing a rule

The shield titled itself with the first enabled rule whose selection
contained the app, regardless of whether that rule was the one actually
blocking. When several rules covered the same app (e.g. a dormant
schedule + the active limit) it named the wrong rule.

Drop the rule-name lookup entirely: every shield now carries a generic
"App Blocked" title. Open-limit shields keep their functional detail
(running count + "Open (N left)" button) beneath that title. The
text-only decision moves into a pure, unit-tested ShieldPresentation in
Shared/, and the now-dead ShieldLookup.snapshot(containingApplication:)
is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:32:26 -04:00
parent d92a8588bb
commit e5f571a63e
5 changed files with 127 additions and 52 deletions

View File

@@ -9,28 +9,29 @@ import UIKit
/// Customizes the system shield. Apps under an open-limit rule show how many /// Customizes the system shield. Apps under an open-limit rule show how many
/// opens were used and offer an "Open" secondary button while opens remain; /// opens were used and offer an "Open" secondary button while opens remain;
/// everything else gets a plain blocked shield named after its rule. /// everything else gets a plain blocked shield. Shields never name a rule the
/// text is decided by the pure `ShieldPresentation`.
final class ShieldConfigurationExtension: ShieldConfigurationDataSource { final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
override func configuration(shielding application: Application) -> ShieldConfiguration { override func configuration(shielding application: Application) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) } guard let token = application.token else { return configuration(for: .blocked) }
return configuration(forApplicationToken: token) return configuration(forApplicationToken: token)
} }
override func configuration( override func configuration(
shielding application: Application, in category: ActivityCategory shielding application: Application, in category: ActivityCategory
) -> ShieldConfiguration { ) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) } guard let token = application.token else { return configuration(for: .blocked) }
return configuration(forApplicationToken: token) return configuration(forApplicationToken: token)
} }
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration { override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil) configuration(for: .blocked)
} }
override func configuration( override func configuration(
shielding webDomain: WebDomain, in category: ActivityCategory shielding webDomain: WebDomain, in category: ActivityCategory
) -> ShieldConfiguration { ) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil) configuration(for: .blocked)
} }
private func configuration( private func configuration(
@@ -41,45 +42,26 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
let snapshot = ShieldLookup.openLimitSnapshot( let snapshot = ShieldLookup.openLimitSnapshot(
containingApplication: token, in: snapshots) containingApplication: token, in: snapshots)
else { else {
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name return configuration(for: .blocked)
return blockedConfiguration(ruleName: name)
} }
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now) let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
let remaining = max(0, snapshot.maxOpens - usage.opensUsed) return configuration(
guard remaining > 0 else { for: .openLimit(
return ShieldConfiguration( opensUsed: usage.opensUsed,
backgroundBlurStyle: .systemMaterial, maxOpens: snapshot.maxOpens,
title: .init(text: snapshot.name, color: .label), sessionMinutes: MonitoringPlan.openSessionMinutes))
subtitle: .init(
text: "No opens left today — the block lifts tomorrow.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue
)
}
return ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: snapshot.name, color: .label),
subtitle: .init(
text: "Opened \(usage.opensUsed) of \(snapshot.maxOpens) times today. "
+ "Each open lasts \(MonitoringPlan.openSessionMinutes) minutes.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: .init(
text: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)",
color: .systemBlue)
)
} }
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration { private func configuration(for presentation: ShieldPresentation) -> ShieldConfiguration {
ShieldConfiguration( ShieldConfiguration(
backgroundBlurStyle: .systemMaterial, backgroundBlurStyle: .systemMaterial,
title: .init(text: ruleName ?? "Blocked", color: .label), title: .init(text: presentation.title, color: .label),
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel), subtitle: .init(text: presentation.subtitle, color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white), primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: presentation.secondaryButton.map {
.init(text: $0, color: .systemBlue)
}
) )
} }
} }

View File

@@ -0,0 +1,53 @@
//
// ShieldPresentationTests.swift
// OpenAppLockTests
//
import Testing
@testable import OpenAppLock
@Suite("Shield presentation")
struct ShieldPresentationTests {
@Test("Fully-blocked shield is generic and names no rule")
func blockedIsGeneric() {
let presentation = ShieldPresentation.blocked
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle == "This app is blocked by OpenAppLock.")
#expect(presentation.secondaryButton == nil)
}
@Test("Open-limit shield with opens remaining shows counts and an Open button")
func openLimitWithOpensRemaining() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 2, maxOpens: 5, sessionMinutes: 15)
// Title is generic never the rule name.
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle.contains("Opened 2 of 5 times today"))
#expect(presentation.subtitle.contains("15 minutes"))
#expect(presentation.secondaryButton == "Open (3 left)")
}
@Test("A single remaining open reads in the singular")
func openLimitSingularRemaining() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 4, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.secondaryButton == "Open (1 left)")
}
@Test("Spent open-limit shield drops the Open button and reads as blocked")
func openLimitExhausted() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 5, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle == "No opens left today — the block lifts tomorrow.")
#expect(presentation.secondaryButton == nil)
}
@Test("Over-spending never yields a negative or non-nil Open button")
func openLimitOverSpent() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 9, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.secondaryButton == nil)
}
}

View File

@@ -29,16 +29,4 @@ enum ShieldLookup {
.categoryTokens.contains(token) .categoryTokens.contains(token)
} }
} }
/// Any enabled rule whose selection includes the application (for naming
/// plain blocked shields).
static func snapshot(
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.applicationTokens.contains(token)
}
}
} }

View File

@@ -0,0 +1,47 @@
//
// ShieldPresentation.swift
// OpenAppLock
//
/// The text a shield shows, decided independently of any UI type so it can be
/// unit tested. Shields never name the rule that blocks an app: when several
/// rules cover the same app the responsible rule can't be told apart, so every
/// shield carries the same generic "App Blocked" title. Open-limit shields add
/// their functional detail (remaining opens + an "Open" button) beneath it.
struct ShieldPresentation: Equatable {
let title: String
let subtitle: String
/// Label for the secondary "Open" button, or `nil` when the shield offers
/// no way through (a full block, or a spent open-limit budget).
let secondaryButton: String?
static let blockedTitle = "App Blocked"
/// A plain, fully-blocked app: no counts, no way through.
static let blocked = ShieldPresentation(
title: blockedTitle,
subtitle: "This app is blocked by OpenAppLock.",
secondaryButton: nil
)
/// An open-limit shield. While opens remain it shows the running count and
/// an "Open (N left)" button; once spent it reads like a plain block.
static func openLimit(
opensUsed: Int, maxOpens: Int, sessionMinutes: Int
) -> ShieldPresentation {
let remaining = max(0, maxOpens - opensUsed)
guard remaining > 0 else {
return ShieldPresentation(
title: blockedTitle,
subtitle: "No opens left today — the block lifts tomorrow.",
secondaryButton: nil
)
}
return ShieldPresentation(
title: blockedTitle,
subtitle: "Opened \(opensUsed) of \(maxOpens) times today. "
+ "Each open lasts \(sessionMinutes) minutes.",
secondaryButton: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)"
)
}
}

View File

@@ -485,9 +485,14 @@ Xh", "Xh left") is **derived**, not stored.
`OpenSessionStore` reports a still-running granted open for it — so the `OpenSessionStore` reports a still-running granted open for it — so the
foreground loop establishes the turnstile for newly created rules and never foreground loop establishes the turnstile for newly created rules and never
re-locks an app mid-session. re-locks an app mid-session.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit - **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): every shield
shields show "Opened X of N times today" with an "Open (Y left)" secondary carries the same generic **"App Blocked"** title — rule names are never shown,
button; other shields show the blocking rule's name. since the rule a shield is attributed to cannot be determined reliably when
several rules cover the same app. Open-limit shields keep their functional
detail under that title ("Opened X of N times today" with an "Open (Y left)"
secondary button while opens remain); all other shields just read "This app is
blocked by OpenAppLock." The text-only decision lives in the pure, unit-tested
`ShieldPresentation` (in `Shared/`).
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press - **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, records the session spends one open in the ledger, lifts the rule's shield, records the session
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session