diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 5102f3ad..fe61cf8d 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -110,7 +110,18 @@ class PushController { /// Re-fetches the notification authorization status from the system. func refreshAuthorizationStatus() async { - authorizationStatus = await Self.fetchStatus() + let current = await Self.fetchStatus() + let previous = UserDefaults.lastNotificationAuthStatus + .flatMap(UNAuthorizationStatus.init(rawValue:)) + + authorizationStatus = current + + if let from = current.previousIfDenied(from: previous) { + Analytics.pushPermissionDenied(from: from) + } + if previous != current { + UserDefaults.lastNotificationAuthStatus = current.rawValue + } } // MARK: - Firebase - @@ -245,3 +256,9 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification extension PushController { static let mock: PushController = PushController(owner: .mock, client: .mock) } + +@MainActor +extension UserDefaults { + @Defaults(.lastNotificationAuthStatus) + static var lastNotificationAuthStatus: Int? +} diff --git a/Flipcash/Keychain/Defaults.swift b/Flipcash/Keychain/Defaults.swift index a6906956..ca0ef72d 100644 --- a/Flipcash/Keychain/Defaults.swift +++ b/Flipcash/Keychain/Defaults.swift @@ -38,8 +38,10 @@ enum DefaultsKey: String { case storedTokenMint = "com.flipcash.token.storedTokenMint" case betaFlags = "com.flipcash.betaFlags" - + case pendingPurchase = "com.flipcash.iap.pendingPurchase" + + case lastNotificationAuthStatus = "com.flipcash.push.lastAuthorizationStatus" // Legacy diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 63a609aa..ec5e9ba0 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -6,6 +6,7 @@ // import Foundation +import UserNotifications import FlipcashCore // MARK: - Domain Event Enums - @@ -89,6 +90,10 @@ extension Analytics { case parse = "Deeplink: Parse" case routed = "Deeplink: Routed" } + + enum PushPermissionEvent: String, AnalyticsEvent { + case denied = "Push Permission Denied" + } } // MARK: - General - @@ -271,6 +276,37 @@ extension Analytics { } } +// MARK: - Push Permission - + +extension Analytics { + static func pushPermissionDenied(from previous: UNAuthorizationStatus) { + track(event: PushPermissionEvent.denied, properties: [ + .from: previous.analyticsName, + ]) + } +} + +extension UNAuthorizationStatus { + var analyticsName: String { + switch self { + case .notDetermined: "notDetermined" + case .denied: "denied" + case .authorized: "authorized" + case .provisional: "provisional" + case .ephemeral: "ephemeral" + @unknown default: "unknown" + } + } + + /// Silent on `nil` baseline so that shipping this update does not emit a + /// spurious denial event for users whose push is already denied at first + /// foreground after install. + func previousIfDenied(from previous: UNAuthorizationStatus?) -> UNAuthorizationStatus? { + guard let previous, previous != .denied, self == .denied else { return nil } + return previous + } +} + // MARK: - Deeplinks - extension Analytics { @@ -326,6 +362,8 @@ extension Analytics { case type = "Type" case error = "Error" case url = "URL" + + case from = "From" } } diff --git a/FlipcashTests/PushPermissionAnalyticsTests.swift b/FlipcashTests/PushPermissionAnalyticsTests.swift new file mode 100644 index 00000000..0e218649 --- /dev/null +++ b/FlipcashTests/PushPermissionAnalyticsTests.swift @@ -0,0 +1,48 @@ +// +// PushPermissionAnalyticsTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import UserNotifications +@testable import Flipcash + +@Suite("Push Permission Analytics") +struct PushPermissionAnalyticsTests { + + @Test( + "previousIfDenied returns previous only on transition into .denied", + arguments: [ + (UNAuthorizationStatus?.none, UNAuthorizationStatus.denied, UNAuthorizationStatus?.none), + (.some(.authorized), .authorized, nil), + (.some(.authorized), .denied, .authorized), + (.some(.notDetermined), .denied, .notDetermined), + (.some(.provisional), .denied, .provisional), + (.some(.denied), .authorized, nil), + (.some(.denied), .notDetermined, nil), + (.some(.authorized), .provisional, nil), + ] as [(UNAuthorizationStatus?, UNAuthorizationStatus, UNAuthorizationStatus?)] + ) + func previousIfDenied( + previous: UNAuthorizationStatus?, + current: UNAuthorizationStatus, + expected: UNAuthorizationStatus? + ) { + #expect(current.previousIfDenied(from: previous) == expected) + } + + @Test( + "analyticsName covers every known UNAuthorizationStatus case", + arguments: [ + (UNAuthorizationStatus.notDetermined, "notDetermined"), + (.denied, "denied"), + (.authorized, "authorized"), + (.provisional, "provisional"), + (.ephemeral, "ephemeral"), + ] + ) + func analyticsName(status: UNAuthorizationStatus, expected: String) { + #expect(status.analyticsName == expected) + } +}