Skip to content

Commit

Permalink
✨ Add push install verification to debugger
Browse files Browse the repository at this point in the history
  • Loading branch information
mmaatttt authored and iujames committed Sep 17, 2024
1 parent bae4160 commit c64fe5f
Show file tree
Hide file tree
Showing 7 changed files with 276 additions and 3 deletions.
1 change: 1 addition & 0 deletions Sources/AppcuesKit/Appcues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions Sources/AppcuesKit/Presentation/Debugger/Panel/DebugUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal enum DebugUI {
struct MainPanelView: View {
let apiVerifier: APIVerifier
let deepLinkVerifier: DeepLinkVerifier
let pushVerifier: PushVerifier

@ObservedObject var viewModel: DebugViewModel

Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
224 changes: 224 additions & 0 deletions Sources/AppcuesKit/Presentation/Debugger/PushVerifier.swift
Original file line number Diff line number Diff line change
@@ -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<StatusItem, Never>()
var publisher: AnyPublisher<StatusItem, Never> { 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
}
}
3 changes: 3 additions & 0 deletions Sources/AppcuesKit/Presentation/Debugger/UIDebugger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoggedEvent, Never>()
var eventPublisher: AnyPublisher<LoggedEvent, Never> { subject.eraseToAnyPublisher() }
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/AppcuesKit/Push/ParsedNotification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
13 changes: 12 additions & 1 deletion Sources/AppcuesKit/Push/PushMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit c64fe5f

Please sign in to comment.