From af1ec7dc8a15825297f817b8c17fe363e0e52d93 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 12 Aug 2026 21:46:34 -0400 Subject: [PATCH] Test retired tvOS badge cleanup behavior --- ContextPanel.xcodeproj/project.pbxproj | 4 ++ Sources/ContextPanelTV/TVSystemSurfaces.swift | 16 +++---- .../TVRetiredProviderBadgeCleanup.swift | 24 ++++++++++ .../TVTopShelfTests.swift | 46 +++++++++++++++++-- 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 Sources/ContextPanelTVSupport/TVRetiredProviderBadgeCleanup.swift diff --git a/ContextPanel.xcodeproj/project.pbxproj b/ContextPanel.xcodeproj/project.pbxproj index cf55dcf..d502ba5 100644 --- a/ContextPanel.xcodeproj/project.pbxproj +++ b/ContextPanel.xcodeproj/project.pbxproj @@ -207,6 +207,7 @@ B2B705342CCBD83E5E2CEB09 /* libContextPanelValidationFixturesTV.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F4ADEA4E6FA132BEE434281A /* libContextPanelValidationFixturesTV.a */; }; B2D8ECEDBF2665949BA785F3 /* RuntimeReceiptCloudKitSyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A72867B173DBDFD1F46C8709 /* RuntimeReceiptCloudKitSyncStore.swift */; }; B32B9A08B17CEA125482D04F /* WatchLimitDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D628E750682BA2235D68F3 /* WatchLimitDisplay.swift */; }; + B55EEF9F34C86009E8B5A896 /* TVRetiredProviderBadgeCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A388CA93215EB24001748C7 /* TVRetiredProviderBadgeCleanup.swift */; }; B59F4707B276A4C93680E7F6 /* SnapshotFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03C19BEA1C610711A048EB7 /* SnapshotFreshness.swift */; }; B678E00990494E22F2C547F8 /* LimitWarningWebhook.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE09AE825E3B368ED89CD521 /* LimitWarningWebhook.swift */; }; B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D8515D047F5A8117DB762C /* SnapshotStore.swift */; }; @@ -775,6 +776,7 @@ 352F396977388C817E5A692E /* libContextPanelCoreTV.a */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = archive.ar; path = libContextPanelCoreTV.a; sourceTree = BUILT_PRODUCTS_DIR; }; 3682A52E7297D6D89A6749AC /* ContextPanelTVTopShelfExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ContextPanelTVTopShelfExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 380420B70E6A75C9E356322E /* ContextPanelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelApp.swift; sourceTree = ""; }; + 3A388CA93215EB24001748C7 /* TVRetiredProviderBadgeCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVRetiredProviderBadgeCleanup.swift; sourceTree = ""; }; 3BD1D2D323E49D0BB93C1999 /* libContextPanelValidationGalleryUI.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libContextPanelValidationGalleryUI.a; sourceTree = BUILT_PRODUCTS_DIR; }; 3C28E693A6A73B61184A5288 /* CompanionRemoteMerge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionRemoteMerge.swift; sourceTree = ""; }; 3F049DC36AB470FDC9A5C8B8 /* libContextPanelValidationFixturesCompanion.a */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = archive.ar; path = libContextPanelValidationFixturesCompanion.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1164,6 +1166,7 @@ 25038A5D95D954F0AB88FEA2 /* TVCloudKitNotificationPolicy.swift */, D7CCA2AD1263BFDAB028A69A /* TVLocalCacheLocations.swift */, 8DDDE0CD2A4463C733CFD425 /* TVPreferences.swift */, + 3A388CA93215EB24001748C7 /* TVRetiredProviderBadgeCleanup.swift */, BAC6B24F1876E6D7627E2B82 /* TVRunwayPresentation.swift */, 544FE80ADC4650CDB85B4640 /* TVSyncReceipt.swift */, E435106FACB9E5DD2F9C2651 /* TVTopShelfDocument.swift */, @@ -2577,6 +2580,7 @@ 828DEBC58258F34231E2D514 /* TVCloudKitNotificationPolicy.swift in Sources */, 025C06128C86B7E034BD0A0C /* TVLocalCacheLocations.swift in Sources */, 91F566F9CBB9D43F199AA29F /* TVPreferences.swift in Sources */, + B55EEF9F34C86009E8B5A896 /* TVRetiredProviderBadgeCleanup.swift in Sources */, E3F82D291A06A0393BD1B9F3 /* TVRunwayPresentation.swift in Sources */, 70156A1E7C885A1E66F95D68 /* TVSyncReceipt.swift in Sources */, EE877A67DE1B045C092038FB /* TVTopShelfDocument.swift in Sources */, diff --git a/Sources/ContextPanelTV/TVSystemSurfaces.swift b/Sources/ContextPanelTV/TVSystemSurfaces.swift index 98d322c..630bbd6 100644 --- a/Sources/ContextPanelTV/TVSystemSurfaces.swift +++ b/Sources/ContextPanelTV/TVSystemSurfaces.swift @@ -126,9 +126,6 @@ final class TVRuntimeReceiptRelayProvider { @MainActor final class ContextPanelTVAppDelegate: NSObject, UIApplicationDelegate { - private static let retiredBadgeExpiryRequestIdentifier = "context-panel-provider-badge-expiry" - private static let retiredProviderBadgesPreferenceKey = "tv-provider-badges-enabled" - private let remoteStore = CompanionCloudKitSyncStoreFactory.make() let runtimeReceiptRelayProvider = TVRuntimeReceiptRelayProvider() private let notificationCenter = UNUserNotificationCenter.current() @@ -254,13 +251,16 @@ final class ContextPanelTVAppDelegate: NSObject, UIApplicationDelegate { } private func clearRetiredProviderBadge() { - UserDefaults.standard.removeObject(forKey: Self.retiredProviderBadgesPreferenceKey) - notificationCenter.removePendingNotificationRequests( - withIdentifiers: [Self.retiredBadgeExpiryRequestIdentifier] + let badgeCount = TVRetiredProviderBadgeCleanup( + providerAlertStateURL: TVLocalCacheLocations.live().providerAlertStateURL + ).perform( + defaults: .standard, + removePendingNotificationRequests: { [notificationCenter] identifiers in + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) + } ) - try? FileManager.default.removeItem(at: TVLocalCacheLocations.live().providerAlertStateURL) Task { [notificationCenter] in - try? await notificationCenter.setBadgeCount(0) + try? await notificationCenter.setBadgeCount(badgeCount) } } } diff --git a/Sources/ContextPanelTVSupport/TVRetiredProviderBadgeCleanup.swift b/Sources/ContextPanelTVSupport/TVRetiredProviderBadgeCleanup.swift new file mode 100644 index 0000000..3488174 --- /dev/null +++ b/Sources/ContextPanelTVSupport/TVRetiredProviderBadgeCleanup.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct TVRetiredProviderBadgeCleanup: Sendable { + public static let expiryRequestIdentifier = "context-panel-provider-badge-expiry" + public static let badgesPreferenceKey = "tv-provider-badges-enabled" + + public let providerAlertStateURL: URL + + public init(providerAlertStateURL: URL) { + self.providerAlertStateURL = providerAlertStateURL + } + + @discardableResult + public func perform( + defaults: UserDefaults, + fileManager: FileManager = .default, + removePendingNotificationRequests: ([String]) -> Void + ) -> Int { + defaults.removeObject(forKey: Self.badgesPreferenceKey) + removePendingNotificationRequests([Self.expiryRequestIdentifier]) + try? fileManager.removeItem(at: providerAlertStateURL) + return 0 + } +} diff --git a/Tests/ContextPanelCoreTests/TVTopShelfTests.swift b/Tests/ContextPanelCoreTests/TVTopShelfTests.swift index 1d77a0f..7d5e230 100644 --- a/Tests/ContextPanelCoreTests/TVTopShelfTests.swift +++ b/Tests/ContextPanelCoreTests/TVTopShelfTests.swift @@ -741,16 +741,52 @@ import Testing #expect(!source.contains("fileManager.urls(for: .cachesDirectory")) } -@Test func tvOSRetiredProviderBadgeIsClearedWithoutSchedulingReplacementNotifications() throws { +@Test func tvRetiredProviderBadgeCleanupClearsStoredStateAndReturnsZeroBadgeCount() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "context-panel-tv-badge-cleanup-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let suiteName = "ContextPanelTVBadgeCleanupTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let alertStateURL = directory.appending(path: "provider-alert-state.json") + try Data("retired-state".utf8).write(to: alertStateURL) + defaults.set(true, forKey: TVRetiredProviderBadgeCleanup.badgesPreferenceKey) + var removedRequestIdentifiers: [[String]] = [] + let cleanup = TVRetiredProviderBadgeCleanup(providerAlertStateURL: alertStateURL) + + #expect(TVRetiredProviderBadgeCleanup.expiryRequestIdentifier == "context-panel-provider-badge-expiry") + #expect(TVRetiredProviderBadgeCleanup.badgesPreferenceKey == "tv-provider-badges-enabled") + let badgeCount = cleanup.perform( + defaults: defaults, + removePendingNotificationRequests: { removedRequestIdentifiers.append($0) } + ) + + #expect(badgeCount == 0) + #expect(removedRequestIdentifiers == [[TVRetiredProviderBadgeCleanup.expiryRequestIdentifier]]) + #expect(defaults.object(forKey: TVRetiredProviderBadgeCleanup.badgesPreferenceKey) == nil) + #expect(!FileManager.default.fileExists(atPath: alertStateURL.path)) + + let repeatedBadgeCount = cleanup.perform( + defaults: defaults, + removePendingNotificationRequests: { removedRequestIdentifiers.append($0) } + ) + #expect(repeatedBadgeCount == 0) + #expect(removedRequestIdentifiers == [ + [TVRetiredProviderBadgeCleanup.expiryRequestIdentifier], + [TVRetiredProviderBadgeCleanup.expiryRequestIdentifier], + ]) +} + +@Test func tvOSAppDelegateDoesNotScheduleReplacementProviderBadgeNotifications() throws { let sourceURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appending(path: "Sources/ContextPanelTV/TVSystemSurfaces.swift") let source = try String(contentsOf: sourceURL, encoding: .utf8) - #expect(source.contains("clearRetiredProviderBadge()")) - #expect(source.contains("notificationCenter.setBadgeCount(0)")) - #expect(source.contains("retiredBadgeExpiryRequestIdentifier")) + #expect(source.contains("TVRetiredProviderBadgeCleanup(")) + #expect(source.contains("notificationCenter.setBadgeCount(badgeCount)")) #expect(!source.contains("UNTimeIntervalNotificationTrigger")) - #expect(!source.contains("requestAuthorization(options: [.badge])")) + #expect(!source.contains(".badge")) } private actor TVDeadlineBlocker {