From c64fe5f30197e0c5d3ecf8d758497f3bafa1ca0e Mon Sep 17 00:00:00 2001 From: Matt Hayashida Date: Fri, 8 Mar 2024 15:31:59 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20push=20install=20verification?= =?UTF-8?q?=20to=20debugger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/AppcuesKit/Appcues.swift | 1 + .../FloatingView/DebugViewController.swift | 14 +- .../Presentation/Debugger/Panel/DebugUI.swift | 22 ++ .../Presentation/Debugger/PushVerifier.swift | 224 ++++++++++++++++++ .../Presentation/Debugger/UIDebugger.swift | 3 + .../AppcuesKit/Push/ParsedNotification.swift | 2 + Sources/AppcuesKit/Push/PushMonitor.swift | 13 +- 7 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 Sources/AppcuesKit/Presentation/Debugger/PushVerifier.swift diff --git a/Sources/AppcuesKit/Appcues.swift b/Sources/AppcuesKit/Appcues.swift index 64c1c47f6..b7a17b7a3 100644 --- a/Sources/AppcuesKit/Appcues.swift +++ b/Sources/AppcuesKit/Appcues.swift @@ -358,6 +358,7 @@ public class Appcues: NSObject { container.registerLazy(PushMonitoring.self, initializer: PushMonitor.init) if #available(iOS 13.0, *) { + container.registerLazy(PushVerifier.self, initializer: PushVerifier.init) container.registerLazy(DeepLinkHandling.self, initializer: DeepLinkHandler.init) container.registerLazy(UIDebugging.self, initializer: UIDebugger.init) container.registerLazy(ExperienceLoading.self, initializer: ExperienceLoader.init) diff --git a/Sources/AppcuesKit/Presentation/Debugger/FloatingView/DebugViewController.swift b/Sources/AppcuesKit/Presentation/Debugger/FloatingView/DebugViewController.swift index 0fe2db0f3..a397dadda 100644 --- a/Sources/AppcuesKit/Presentation/Debugger/FloatingView/DebugViewController.swift +++ b/Sources/AppcuesKit/Presentation/Debugger/FloatingView/DebugViewController.swift @@ -31,12 +31,21 @@ internal class DebugViewController: UIViewController { let logger: DebugLogger let apiVerifier: APIVerifier let deepLinkVerifier: DeepLinkVerifier - - init(viewModel: DebugViewModel, logger: DebugLogger, apiVerifier: APIVerifier, deepLinkVerifier: DeepLinkVerifier, mode: DebugMode) { + let pushVerifier: PushVerifier + + init( + viewModel: DebugViewModel, + logger: DebugLogger, + apiVerifier: APIVerifier, + deepLinkVerifier: DeepLinkVerifier, + pushVerifier: PushVerifier, + mode: DebugMode + ) { self.viewModel = viewModel self.logger = logger self.apiVerifier = apiVerifier self.deepLinkVerifier = deepLinkVerifier + self.pushVerifier = pushVerifier self.mode = mode super.init(nibName: nil, bundle: nil) } @@ -67,6 +76,7 @@ internal class DebugViewController: UIViewController { let viewController = UIHostingController(rootView: DebugUI.MainPanelView( apiVerifier: apiVerifier, deepLinkVerifier: deepLinkVerifier, + pushVerifier: pushVerifier, viewModel: viewModel ).environmentObject(logger)) addChild(viewController) diff --git a/Sources/AppcuesKit/Presentation/Debugger/Panel/DebugUI.swift b/Sources/AppcuesKit/Presentation/Debugger/Panel/DebugUI.swift index f5db1665f..5db296b7d 100644 --- a/Sources/AppcuesKit/Presentation/Debugger/Panel/DebugUI.swift +++ b/Sources/AppcuesKit/Presentation/Debugger/Panel/DebugUI.swift @@ -14,6 +14,7 @@ internal enum DebugUI { struct MainPanelView: View { let apiVerifier: APIVerifier let deepLinkVerifier: DeepLinkVerifier + let pushVerifier: PushVerifier @ObservedObject var viewModel: DebugViewModel @@ -23,6 +24,7 @@ internal enum DebugUI { InstalledRow(accountID: viewModel.accountID, applicationID: viewModel.applicationID) ConnectedRow(apiVerifier: apiVerifier) DeepLinkRow(deepLinkVerifier: deepLinkVerifier) + PushRow(pushVerifier: pushVerifier) ScreensRow(isTrackingScreens: viewModel.trackingPages) UserRow(currentUserID: viewModel.currentUserID, isAnonymous: viewModel.isAnonymous) GroupRow(currentGroupID: viewModel.currentGroupID) @@ -144,6 +146,26 @@ internal enum DebugUI { } } + struct PushRow: View { + let pushVerifier: PushVerifier + + @State var statusItem = StatusItem(status: .pending, title: "Push Notifications Configured") + + var body: some View { + ListItemRowView(item: statusItem) { + Button { + pushVerifier.verifyPush() + } label: { + Image(systemName: "arrow.triangle.2.circlepath").imageScale(.small) + } + .foregroundColor(.secondary) + } + .onReceive(pushVerifier.publisher) { + statusItem = $0 + } + } + } + struct ScreensRow: View { let isTrackingScreens: Bool diff --git a/Sources/AppcuesKit/Presentation/Debugger/PushVerifier.swift b/Sources/AppcuesKit/Presentation/Debugger/PushVerifier.swift new file mode 100644 index 000000000..38c0167f5 --- /dev/null +++ b/Sources/AppcuesKit/Presentation/Debugger/PushVerifier.swift @@ -0,0 +1,224 @@ +// +// PushVerifier.swift +// AppcuesKit +// +// Created by Matt on 2024-03-08. +// Copyright © 2024 Appcues. All rights reserved. +// + +import UIKit +import Combine + +@available(iOS 13.0, *) +internal class PushVerifier { + enum ErrorMessage: CustomStringConvertible { + case noToken + case notAuthorized + case permissionDenied + case unexpectedStatus + case noNotificationDelegate + case noReceiveHandler + case multipleCompletions + case noSDKResponse + + // Verification flow errors + case tokenMismatch + case responseInitFail + + var description: String { + switch self { + case .noToken: + return "Error 1: No push token registered with Appcues" + case .notAuthorized: + return "Error 2: Notification permissions not requested" + case .permissionDenied: + return "Error 3: Notification permissions denied" + case .unexpectedStatus: + return "Error 4: Unexpected notification permission status" + case .noNotificationDelegate: + return "Error 5: Notification delegate is not set" + case .noReceiveHandler: + return "Error 6: Receive handler not implemented" + case .multipleCompletions: + return "Error 7: Receive completion called too many times" + case .noSDKResponse: + return "Error 8: Receive response not passed to SDK" + case .tokenMismatch: + return "Error 10: Unexpected result" + case .responseInitFail: + return "Error 11: Unexpected result" + } + } + } + + static let title = "Push Notifications Configured" + + private let config: Appcues.Config + private let storage: DataStoring + private let networking: Networking + private let pushMonitor: PushMonitoring + + /// Unique value to pass through a deep link to verify handling. + private var pushVerificationToken: String? + + private var errors: [ErrorMessage] = [] { + didSet { + if !errors.isEmpty { + subject.send( + StatusItem(status: .unverified, title: PushVerifier.title, subtitle: errors.map(\.description).joined(separator: "\n")) + ) + } + } + } + + private let subject = PassthroughSubject() + var publisher: AnyPublisher { subject.eraseToAnyPublisher() } + + init(container: DIContainer) { + self.config = container.resolve(Appcues.Config.self) + self.storage = container.resolve(DataStoring.self) + self.networking = container.resolve(Networking.self) + self.pushMonitor = container.resolve(PushMonitoring.self) + } + + func verifyPush(token: UUID = UUID()) { + subject.send(StatusItem(status: .pending, title: PushVerifier.title, subtitle: nil)) + + // If the previous verification attempt errored because notification permissions haven't been requested, + // request them before trying again. + if errors.contains(.notAuthorized) { + errors = [] + requestPush() + return + } + + errors = [] + + verifyDeviceConfiguration() + verifyClientImplementation(token: token.uuidString) + } + + func receivedVerification(token: String) { + if token == pushVerificationToken { + verifyServerComponents(token: token) + } else { + errors.append(.tokenMismatch) + } + + pushVerificationToken = nil + } + + private func requestPush() { + let options: UNAuthorizationOptions = [.alert, .sound, .badge] + UNUserNotificationCenter.current().requestAuthorization(options: options) { _, _ in + self.pushMonitor.refreshPushStatus { _ in + DispatchQueue.main.async { + self.verifyPush() + } + } + } + } + + private func verifyDeviceConfiguration() { + if storage.pushToken == nil { + errors.append(.noToken) + } + + switch pushMonitor.pushAuthorizationStatus { + case .notDetermined, .provisional: + errors.append(.notAuthorized) + case .denied: + errors.append(.permissionDenied) + case .authorized: + break + case .ephemeral: + fallthrough + @unknown default: + errors.append(.unexpectedStatus) + } + } + + private func verifyClientImplementation(token: String) { + let notificationCenter = UNUserNotificationCenter.current() + guard let notificationDelegate = notificationCenter.delegate else { + errors.append(.noNotificationDelegate) + return + } + + guard let receiveHandler = notificationDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:) else { + errors.append(.noReceiveHandler) + return + } + + guard let mockResponse = UNNotificationResponse.mock(token: token) else { + errors.append(.responseInitFail) + return + } + + pushVerificationToken = token + var completionCount = 0 + receiveHandler(notificationCenter, mockResponse) { [weak self] in + completionCount += 1 + if completionCount > 1 { + self?.errors.append(.multipleCompletions) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + // in a valid implementation pushVerificationToken will be nil by now + // from receivedVerification(token:) being called from PushMonitor + if self.pushVerificationToken != nil { + self.errors.append(.noSDKResponse) + self.pushVerificationToken = nil + } + } + } + + private func verifyServerComponents(token: String) { + // TODO: trigger remote call to verify E2E + if errors.isEmpty { + subject.send(StatusItem(status: .verified, title: PushVerifier.title)) + } + } +} + +private extension UNNotificationResponse { + final class KeyedArchiver: NSKeyedArchiver { + override func decodeObject(forKey _: String) -> Any { "" } + + deinit { + // Avoid a console warning + finishEncoding() + } + } + + static func mock( + token: String, + actionIdentifier: String = UNNotificationDefaultActionIdentifier + ) -> UNNotificationResponse? { + guard let response = UNNotificationResponse(coder: KeyedArchiver()), + let notification = UNNotification(coder: KeyedArchiver()) else { + return nil + } + + let content = UNMutableNotificationContent() + content.userInfo = [ + "_appcues_internal": true, + "appcues_account_id": "", + "appcues_user_id": "", + "appcues_notification_id": token + ] + + let request = UNNotificationRequest( + identifier: "", + content: content, + trigger: nil + ) + notification.setValue(request, forKey: "request") + + response.setValue(notification, forKey: "notification") + response.setValue(actionIdentifier, forKey: "actionIdentifier") + + return response + } +} diff --git a/Sources/AppcuesKit/Presentation/Debugger/UIDebugger.swift b/Sources/AppcuesKit/Presentation/Debugger/UIDebugger.swift index 3dfff9333..ff42d575a 100644 --- a/Sources/AppcuesKit/Presentation/Debugger/UIDebugger.swift +++ b/Sources/AppcuesKit/Presentation/Debugger/UIDebugger.swift @@ -54,6 +54,7 @@ internal class UIDebugger: UIDebugging { private let notificationCenter: NotificationCenter private let analyticsPublisher: AnalyticsPublishing private let networking: Networking + private let pushVerifier: PushVerifier private let subject = PassthroughSubject() var eventPublisher: AnyPublisher { subject.eraseToAnyPublisher() } @@ -69,6 +70,7 @@ internal class UIDebugger: UIDebugging { self.analyticsPublisher = container.resolve(AnalyticsPublishing.self) self.notificationCenter = container.resolve(NotificationCenter.self) self.networking = container.resolve(Networking.self) + self.pushVerifier = container.resolve(PushVerifier.self) self.screenCapturer = ScreenCapturer( config: config, @@ -121,6 +123,7 @@ internal class UIDebugger: UIDebugging { logger: debugLogger, apiVerifier: APIVerifier(networking: networking), deepLinkVerifier: DeepLinkVerifier(applicationID: config.applicationID), + pushVerifier: pushVerifier, mode: mode ) rootViewController.delegate = self diff --git a/Sources/AppcuesKit/Push/ParsedNotification.swift b/Sources/AppcuesKit/Push/ParsedNotification.swift index bbb1d1b6b..524eb61d8 100644 --- a/Sources/AppcuesKit/Push/ParsedNotification.swift +++ b/Sources/AppcuesKit/Push/ParsedNotification.swift @@ -20,6 +20,7 @@ internal struct ParsedNotification { let attachmentURL: URL? let attachmentType: String? let isTest: Bool + let isInternal: Bool init?(userInfo: [AnyHashable: Any]) { guard let accountID = userInfo["appcues_account_id"] as? String, @@ -42,5 +43,6 @@ internal struct ParsedNotification { .flatMap { URL(string: $0) } self.attachmentType = userInfo["appcues_attachment_type"] as? String self.isTest = userInfo["appcues_test"] as? Bool ?? false + self.isInternal = userInfo["_appcues_internal"] as? Bool ?? false } } diff --git a/Sources/AppcuesKit/Push/PushMonitor.swift b/Sources/AppcuesKit/Push/PushMonitor.swift index 39ee4483a..06a4e8f93 100644 --- a/Sources/AppcuesKit/Push/PushMonitor.swift +++ b/Sources/AppcuesKit/Push/PushMonitor.swift @@ -9,6 +9,7 @@ import UIKit internal protocol PushMonitoring: AnyObject { + var pushAuthorizationStatus: UNAuthorizationStatus { get } var pushEnabled: Bool { get } var pushBackgroundEnabled: Bool { get } var pushPrimerEligible: Bool { get } @@ -24,7 +25,7 @@ internal class PushMonitor: PushMonitoring { private weak var appcues: Appcues? private let storage: DataStoring - private var pushAuthorizationStatus: UNAuthorizationStatus = .notDetermined + private(set) var pushAuthorizationStatus: UNAuthorizationStatus = .notDetermined var pushEnabled: Bool { pushAuthorizationStatus == .authorized && storage.pushToken != nil @@ -83,6 +84,16 @@ internal class PushMonitor: PushMonitoring { return false } + guard !parsedNotification.isInternal else { + // This is a synthetic notification response from PushVerifier. + if #available(iOS 13.0, *) { + let pushVerifier = appcues.container.resolve(PushVerifier.self) + pushVerifier.receivedVerification(token: parsedNotification.notificationID) + } + completionHandler() + return true + } + // If there's no active session or a user ID mismatch, don't do anything with the notification guard appcues.isActive && parsedNotification.userID == storage.userID else { completionHandler()