Skip to content

Commit

Permalink
✨ Add support for automatic push environment detection
Browse files Browse the repository at this point in the history
  • Loading branch information
mmaatttt authored and iujames committed Sep 17, 2024
1 parent 2d5ca5e commit eea7731
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,7 @@ internal class AutoPropertyDecorator: AnalyticsDecorating {
"_pushToken": pushToken,
"_pushEnabled": pushMonitor.pushEnabled,
"_pushEnabledBackground": pushMonitor.pushBackgroundEnabled,
// TODO: more properties
// _pushSubscriptionStatus
"_pushEnvironment": pushMonitor.pushEnvironment.environmentValue
]

if let language = deviceLanguage {
Expand Down
7 changes: 7 additions & 0 deletions Sources/AppcuesKit/Presentation/Debugger/PushVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ private final class KeyedArchiver: NSKeyedArchiver {
@available(iOS 13.0, *)
internal class PushVerifier {
enum ErrorMessage: Equatable, CustomStringConvertible {
case noPushEnvironment(String)
case noToken
case notAuthorized
case permissionDenied
Expand All @@ -41,6 +42,8 @@ internal class PushVerifier {

var description: String {
switch self {
case .noPushEnvironment(let error):
return "Error 0: Could not determine push environment\n(\(error))"
case .noToken:
return "Error 1: No push token registered with Appcues"
case .notAuthorized:
Expand Down Expand Up @@ -153,6 +156,10 @@ internal class PushVerifier {
}

private func verifyDeviceConfiguration() {
if case .unknown(let reason) = pushMonitor.pushEnvironment {
errors.append(.noPushEnvironment(reason.description))
}

if storage.pushToken == nil {
errors.append(.noToken)
}
Expand Down
144 changes: 144 additions & 0 deletions Sources/AppcuesKit/Push/PushEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//
// PushEnvironment.swift
// Appcues
//
// Created by Matt on 2024-05-21.
//

import UIKit

internal enum PushEnvironment {
case unknown(Reason)
case development
case production

enum PushEnvironmentError: LocalizedError {
case noEntitlementsKey
case noEnvironmentKey
case unexpectedEnvironment(String)

var errorDescription: String? {
switch self {
case .noEntitlementsKey: return "No 'Entitlements' key"
case .noEnvironmentKey: return "No entitlement 'aps-environment' key"
case .unexpectedEnvironment(let value): return "Unexpected 'aps-environment' value '\(value)'"
}
}
}

enum Reason {
case notComputed, error(Error)

var description: String {
switch self {
case .notComputed: return "Value not computed"
case .error(let error): return error.localizedDescription
}
}
}

var environmentValue: String {
// The environment to request from the backend must be "development" or "production".
// If we haven't been able to determine the environment, default to "production".
switch self {
case .development: return "development"
case .unknown, .production: return "production"
}
}

init?(value: String) {
switch value {
case "development": self = .development
case "production": self = .production
default: return nil
}
}
}

extension UIDevice {
enum ProvisioningProfileError: LocalizedError {
case noEmbeddedProfile
case plistScanFailed
case plistSerializationFailed

var errorDescription: String? {
switch self {
case .noEmbeddedProfile: return "No 'embedded.mobileprovision' found in bundle"
case .plistScanFailed: return "Property list scan failed"
case .plistSerializationFailed: return "Property list serialization failed"
}
}
}

func pushEnvironment() -> PushEnvironment {
#if targetEnvironment(simulator)
return .development
#else
do {
let provisioningProfile = try UIDevice.current.provisioningProfile()

guard let entitlements = provisioningProfile["Entitlements"] as? [String: Any] else {
return .unknown(.error(PushEnvironment.PushEnvironmentError.noEntitlementsKey))
}

guard let environment = entitlements["aps-environment"] as? String else {
return .unknown(.error(PushEnvironment.PushEnvironmentError.noEnvironmentKey))
}

guard let pushEnvironment = PushEnvironment(value: environment) else {
return .unknown(.error(PushEnvironment.PushEnvironmentError.unexpectedEnvironment(environment)))
}

return pushEnvironment
} catch ProvisioningProfileError.noEmbeddedProfile {
// App Store apps do not contain an embedded provisioning profile,
// and since we know we're not on a simulator, that means it's "production".
return .production
} catch {
return .unknown(.error(error))
}
#endif
}

private func provisioningProfile() throws -> [String: Any] {
guard let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") else {
throw ProvisioningProfileError.noEmbeddedProfile
}

let binaryString = try String(contentsOfFile: path, encoding: .isoLatin1)

let scanner = Scanner(string: binaryString)
let plistString: String

if #available(iOS 13.0, *) {
guard scanner.scanUpToString("<plist") != nil,
let targetString = scanner.scanUpToString("</plist>")
else {
throw ProvisioningProfileError.plistScanFailed
}
plistString = targetString
} else {
// swiftlint:disable:next legacy_objc_type
var targetString: NSString?

guard scanner.scanUpTo("<plist", into: nil),
scanner.scanUpTo("</plist>", into: &targetString),
let targetString = targetString
else {
throw ProvisioningProfileError.plistScanFailed
}
plistString = targetString as String
}

guard let plistData = (plistString + "</plist>").data(using: .isoLatin1) else {
throw ProvisioningProfileError.plistScanFailed
}

guard let serializedPlist = try PropertyListSerialization.propertyList(from: plistData, options: [], format: nil) as? [String: Any]
else {
throw ProvisioningProfileError.plistSerializationFailed
}

return serializedPlist
}
}
10 changes: 10 additions & 0 deletions Sources/AppcuesKit/Push/PushMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit

internal protocol PushMonitoring: AnyObject {
var pushAuthorizationStatus: UNAuthorizationStatus { get }
var pushEnvironment: PushEnvironment { get }
var pushEnabled: Bool { get }
var pushBackgroundEnabled: Bool { get }
var pushPrimerEligible: Bool { get }
Expand All @@ -32,6 +33,8 @@ internal class PushMonitor: PushMonitoring {

private(set) var pushAuthorizationStatus: UNAuthorizationStatus = .notDetermined

private(set) var pushEnvironment: PushEnvironment = .unknown(.notComputed)

var pushEnabled: Bool {
pushAuthorizationStatus == .authorized && storage.pushToken != nil
}
Expand All @@ -53,6 +56,7 @@ internal class PushMonitor: PushMonitoring {
self.analyticsPublisher = container.resolve(AnalyticsPublishing.self)

refreshPushStatus(publishChange: false)
getPushEnvironment()

NotificationCenter.default.addObserver(
self,
Expand Down Expand Up @@ -211,6 +215,12 @@ internal class PushMonitor: PushMonitoring {
}
}

private func getPushEnvironment() {
DispatchQueue.global(qos: .utility).async { [weak self] in
self?.pushEnvironment = UIDevice.current.pushEnvironment()
}
}

#if DEBUG
func mockPushStatus(_ status: UNAuthorizationStatus) {
pushAuthorizationStatus = status
Expand Down

0 comments on commit eea7731

Please sign in to comment.