Workstation4 / Blog / Swift
Swift Bug Fix Notifications SwiftUI

Owning the full notification lifecycle, and a force-unwrap on the resume banner.

Some bugs crash the app. Others just quietly make it feel a little neglected. This week I fixed one of each in Sequence: notifications that piled up in Notification Center, and a force-unwrap on the home-screen resume banner that could take the whole app down. Neither change is large, but both are the kind of small rough edge I find satisfying to smooth out, and the notification one taught me more about identifier hygiene than I expected.

Why notifications exist at all

Sequence runs timestamp-based timers, which means the engine never relies on a repeating tick to know what time it is. When the app is backgrounded or the screen locks, those ticks stop firing, so the runner derives remaining time from a stored start date and the steps it has already moved through. That design keeps the timer correct, but it also means the app cannot count out loud while it is suspended.

To bridge that gap, I schedule a local UNNotificationRequest with a UNTimeIntervalNotificationTrigger set to the exact number of seconds a step has left. If you start a four minute rest and lock your phone, the system wakes you when the rest is over. The notification is the fallback voice for an engine that is otherwise asleep. There is a second flavor too: for count-up steps with a goal, I schedule a capped series of "over goal" reminders (defaulting to three, no closer than ten minutes apart) so the app can nudge you to wrap up without becoming a pest.

Identifiers are the whole trick

The thing that makes notification cleanup tractable is the identifier scheme. Every request gets a structured, prefix-based string built from the sequence and card UUIDs:

// "sequence.<uuid>.card.<uuid>.countdown"
private func notificationID(sequenceID: UUID, cardID: UUID) -> String {
    "sequence.\(sequenceID.uuidString).card.\(cardID.uuidString).countdown"
}

// "sequence.<uuid>.card.<uuid>.countup.reminder.<n>"

Because identifiers nest from broad to specific, I can clean up at exactly the granularity I need by matching a prefix. removeDeliveredNotifications(withIdentifiers:) wants exact identifiers, not patterns, so I first fetch what is actually in Notification Center and filter:

func clearDeliveredForSequence(sequenceID: UUID) {
    let center = UNUserNotificationCenter.current()
    let prefix = "sequence.\(sequenceID.uuidString)"
    center.getDeliveredNotifications { notifications in
        let toRemove = notifications
            .filter { $0.request.identifier.hasPrefix(prefix) }
            .map { $0.request.identifier }
        if !toRemove.isEmpty {
            center.removeDeliveredNotifications(withIdentifiers: toRemove)
        }
    }
}

The same shape, with a longer prefix, lets me clear just one card's count-up reminders, or with the bare "sequence." prefix, every Sequence notification across the whole system. One naming convention, three cleanup scopes, no extra bookkeeping.

The pile-up

The trouble was that delivered notifications stuck around. Run a few sequences across a morning, especially short rapid-fire timers, and Notification Center filled with stale "timer finished" banners from sessions you had already closed and forgotten. Scheduling worked correctly; cleanup did not exist yet. The system happily delivers a notification and then leaves it sitting there until you manually swipe it away, and that felt like a chore I had quietly handed to the person using the app.

The fix was to clear delivered notifications at the moments where stale ones accumulate, all of them keyed off that prefix scheme:

  • Before scheduling a new countdown. scheduleCountdown calls clearDeliveredForSequence first, so a fresh run does not inherit clutter from the previous step or the last session. This is the one that fixes the rapid-fire short-timer case, where step after step would otherwise leave a trail.
  • On any cancel operation. cancelCountdown removes both the pending request and the delivered one for a single card; cancelAllForSequence sweeps everything under one sequence's prefix when you stop or abandon it.
  • Per card, when reminders are rescheduled. Count-up reminders cancel their own delivered copies before laying down a new batch, so the math that recomputes fire times never doubles up alerts.

A subtle detail in scheduleCountUpReminders: when the app reschedules after coming back from the background, some reminders in the planned series may already be in the past. Rather than firing a burst of late notifications, the code computes how many cadence windows have elapsed and skips those indices entirely, scheduling only the ones still in the future. Cleanup and scheduling have to agree on that, or you get either gaps or duplicates.

Suppress, don't just sweep

The case I thought I would need to handle, clearing notifications when you return to the app, turned out to be better solved earlier in the chain. The real fix for "the timer fired while I was looking right at the app" is the notification delegate's willPresent callback. When a notification would present while the app is in the foreground, I return an empty set of options:

func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    willPresent notification: UNNotification,
    withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
    // Return empty to suppress; the in-app UI already shows what happened
    completionHandler([])
}

If the app is open, the on-screen timer and completion UI already tell you everything the banner would, so the banner never appears and therefore never needs sweeping. That reframed the problem for me: don't deliver-then-clean when you can decline to present in the first place. Cleanup is for the notifications that legitimately fired while you were away; suppression is for the ones that would be redundant.

The latent crash hiding in the resume banner

The second fix was smaller in code and larger in consequence. The sequences list shows a resume banner whenever there is an active run, so you can jump straight back into a routine you paused. The banner reads several fields off that run: the sequence title snapshot, the current card index, and the start time.

The active run lives on an ObservableObject as @Published private(set) var activeRun: ActiveRunSnapshot?. The snapshot itself is a small value type, struct ActiveRunSnapshot: Equatable, Sendable, Codable, which is exactly the right shape for this. The problem was not the data; it was how the view read it. The original guarded on non-nil and then force-unwrapped again for each field:

if activeRunManager.activeRun != nil {
    Text(activeRunManager.activeRun!.sequenceTitleSnapshot)
    Text("Action \(activeRunManager.activeRun!.currentCardIndex + 1)")
    Text(activeRunManager.activeRun!.startedAt, format: .relative(presentation: .named))
}

The check and the reads are separate statements, and activeRun is shared, mutable, published state. The manager clears it in a lot of places: clearActiveRun(), finishing the last card in completeCurrentCard, skipping past the end, validation that finds the underlying sequence was deleted, a resync that decides the run is stale. If any of those fire between the guard and one of the reads, the next force-unwrap hits nil and the app crashes. It is a textbook time-of-check to time-of-use gap. The guard tells you something was true a moment ago; the force-unwrap bets it is still true now.

The replacement is the plain, correct version: bind the value once with if let and read the bound copy everywhere.

if let activeRun = activeRunManager.activeRun {
    Text(activeRun.sequenceTitleSnapshot)
    Text("Action \(activeRun.currentCardIndex + 1)")
    Text(activeRun.startedAt, format: .relative(presentation: .named))
}

Now there is a single read of the optional, and activeRun is a local constant of a value type. Even if the manager nils out its published property mid-render, the view is holding its own copy that cannot be yanked out from under it. The original probably never crashed for most people, which is exactly why latent bugs like this survive: they only fire when the timing lines up, and it lines up more often on slower devices and under heavy multitasking.

A nil check followed by force-unwraps is not really a safety check. It is a bet that nothing changed in between.

Why these count

Neither of these would headline a release. You do not write "now with fewer stale notifications" on an App Store page. But reliability is the sum of dozens of fixes exactly this size. A timer app that leaves debris in Notification Center feels a bit careless, and a sequences list that can crash when you return to it feels fragile, even if the crash is rare. The whole point of timestamp-based timers and a single source of truth is that the app should stay trustworthy when it is out of sight. These two fixes close the small gaps between that intent and the actual behavior.

The lessons I am carrying forward:

  • If you schedule notifications, own their full lifecycle. A structured, prefix-based identifier scheme turns cleanup at three different scopes into one tiny hasPrefix filter instead of three separate ledgers.
  • Prefer declining to present over delivering and sweeping. The willPresent delegate returning [] while in the foreground is cleaner than chasing banners after the fact.
  • A non-nil check plus force-unwraps on shared, published state is a crash waiting for the right timing. Bind once with if let and read the local copy; a Sendable value-type snapshot makes that copy genuinely safe to hold.
  • The bugs that make an app feel unreliable are usually small, cheap to fix, and easy to keep ignoring. It feels good to fix them anyway.
W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →