From 1f7d3e6086a8c8700e956aada464980f91fa51c6 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Thu, 7 May 2026 16:01:12 -0400 Subject: [PATCH] feat: track push permission denials Hooks into the existing on-foreground permission refresh, diffs against a persisted last-known status, and emits a Push Permission Denied event when the user transitions into denied. Silent on first observation per install so existing users whose push is already off do not fire spurious events when this update ships. --- .../Core/Controllers/PushController.swift | 19 +++++++- Flipcash/Keychain/Defaults.swift | 4 +- Flipcash/Utilities/Events.swift | 38 +++++++++++++++ .../PushPermissionAnalyticsTests.swift | 48 +++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 FlipcashTests/PushPermissionAnalyticsTests.swift 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) + } +}