Dactyloidae/mobile/ios/Client/Application/LeanplumIntegration.swift
2026-06-26 21:04:09 -07:00

320 lines
14 KiB
Swift

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import AdSupport
import Shared
import Leanplum
private let LPAppIdKey = "LeanplumAppId"
private let LPProductionKeyKey = "LeanplumProductionKey"
private let LPDevelopmentKeyKey = "LeanplumDevelopmentKey"
private let AppRequestedUserNotificationsPrefKey = "applicationDidRequestUserNotificationPermissionPrefKey"
// FxA Custom Leanplum message template for A/B testing push notifications.
private struct LPMessage {
static let FxAPrePush = "FxA Prepush v1"
static let ArgAcceptAction = "Accept action"
static let ArgCancelAction = "Cancel action"
static let ArgTitleText = "Title.Text"
static let ArgTitleColor = "Title.Color"
static let ArgMessageText = "Message.Text"
static let ArgMessageColor = "Message.Color"
static let ArgAcceptButtonText = "Accept button.Text"
static let ArgCancelButtonText = "Cancel button.Text"
static let ArgCancelButtonTextColor = "Cancel button.Text color"
// These defaults are overridden though Leanplum webUI
static let DefaultAskToAskTitle = NSLocalizedString("Firefox Sync Requires Push", comment: "Default push to ask title")
static let DefaultAskToAskMessage = NSLocalizedString("Firefox will stay in sync faster with Push Notifications enabled.", comment: "Default push to ask message")
static let DefaultOkButtonText = NSLocalizedString("Enable Push", comment: "Default push alert ok button text")
static let DefaultLaterButtonText = NSLocalizedString("Don't Enable", comment: "Default push alert cancel button text")
}
private let log = Logger.browserLogger
enum LPEvent: String {
case firstRun = "E_First_Run"
case secondRun = "E_Second_Run"
case openedApp = "E_Opened_App"
case dismissedOnboarding = "E_Dismissed_Onboarding"
case openedLogins = "Opened Login Manager"
case openedBookmark = "E_Opened_Bookmark"
case openedNewTab = "E_Opened_New_Tab"
case openedPocketStory = "E_Opened_Pocket_Story"
case interactWithURLBar = "E_Interact_With_Search_URL_Area"
case savedBookmark = "E_Saved_Bookmark"
case openedTelephoneLink = "Opened Telephone Link"
case openedMailtoLink = "E_Opened_Mailto_Link"
case saveImage = "E_Download_Media_Saved_Image"
case savedLoginAndPassword = "E_Saved_Login_And_Password"
case clearPrivateData = "E_Cleared_Private_Data"
case downloadedFocus = "E_User_Downloaded_Focus"
case downloadedPocket = "E_User_Downloaded_Pocket"
case userSharedWebpage = "E_User_Tapped_Share_Button"
case signsInFxa = "E_User_Signed_In_To_FxA"
case useReaderView = "E_User_Used_Reader_View"
case trackingProtectionSettings = "E_Tracking_Protection_Settings_Changed"
}
struct LPAttributeKey {
static let focusInstalled = "Focus Installed"
static let klarInstalled = "Klar Installed"
static let signedInSync = "Signed In Sync"
static let mailtoIsDefault = "Mailto Is Default"
static let pocketInstalled = "Pocket Installed"
static let telemetryOptIn = "Telemetry Opt In"
}
struct MozillaAppSchemes {
static let focus = "firefox-focus"
static let focusDE = "firefox-klar"
static let pocket = "pocket"
}
private let supportedLocales = ["en_US", "de_DE", "en_GB", "en_CA", "en_AU", "zh_TW", "en_HK", "en_SG",
"fr_FR", "it_IT", "id_ID", "id_ID", "pt_BR", "pl_PL", "ru_RU", "es_ES", "es_MX"]
private struct LPSettings {
var appId: String
var developmentKey: String
var productionKey: String
}
class LeanPlumClient {
static let shared = LeanPlumClient()
// Setup
private weak var profile: Profile?
private var prefs: Prefs? { return profile?.prefs }
private var enabled: Bool = true
// This defines an external Leanplum varible to enable/disable FxA prepush dialogs.
// The primary result is having a feature flag controlled by Leanplum, and falling back
// to prompting with native push permissions.
private var useFxAPrePush: LPVar = LPVar.define("useFxAPrePush", with: false)
private func isPrivateMode() -> Bool {
// Need to be run on main thread since isInPrivateMode requires to be on the main thread.
assert(Thread.isMainThread)
return UIApplication.isInPrivateMode
}
func isLPEnabled() -> Bool {
return enabled && Leanplum.hasStarted()
}
static func shouldEnable(profile: Profile) -> Bool {
return AppConstants.MOZ_ENABLE_LEANPLUM && (profile.prefs.boolForKey(AppConstants.PrefSendUsageData) ?? true)
}
func setup(profile: Profile) {
self.profile = profile
}
fileprivate func start() {
guard let settings = getSettings(), supportedLocales.contains(Locale.current.identifier), !Leanplum.hasStarted() else {
enabled = false
log.error("LeanplumIntegration - Could not be started")
return
}
if UIDevice.current.name.contains("MozMMADev") {
log.info("LeanplumIntegration - Setting up for Development")
Leanplum.setDeviceId(UIDevice.current.identifierForVendor?.uuidString)
Leanplum.setAppId(settings.appId, withDevelopmentKey: settings.developmentKey)
} else {
log.info("LeanplumIntegration - Setting up for Production")
Leanplum.setAppId(settings.appId, withProductionKey: settings.productionKey)
}
Leanplum.syncResourcesAsync(true)
let attributes: [AnyHashable: Any] = [
LPAttributeKey.mailtoIsDefault: mailtoIsDefault(),
LPAttributeKey.focusInstalled: focusInstalled(),
LPAttributeKey.klarInstalled: klarInstalled(),
LPAttributeKey.pocketInstalled: pocketInstalled(),
LPAttributeKey.signedInSync: profile?.hasAccount() ?? false
]
self.setupCustomTemplates()
Leanplum.start(withUserId: nil, userAttributes: attributes, responseHandler: { _ in
self.track(event: .openedApp)
// We need to check if the app is a clean install to use for
// preventing the What's New URL from appearing.
if self.prefs?.intForKey(IntroViewControllerSeenProfileKey) == nil {
self.prefs?.setString(AppInfo.appVersion, forKey: LatestAppVersionProfileKey)
self.track(event: .firstRun)
} else if self.prefs?.boolForKey("SecondRun") == nil {
self.prefs?.setBool(true, forKey: "SecondRun")
self.track(event: .secondRun)
}
self.checkIfAppWasInstalled(key: PrefsKeys.HasFocusInstalled, isAppInstalled: self.focusInstalled(), lpEvent: .downloadedFocus)
self.checkIfAppWasInstalled(key: PrefsKeys.HasPocketInstalled, isAppInstalled: self.pocketInstalled(), lpEvent: .downloadedPocket)
})
}
// Events
func track(event: LPEvent, withParameters parameters: [String: AnyObject]? = nil) {
guard isLPEnabled() else {
return
}
ensureMainThread {
guard !self.isPrivateMode() else {
return
}
if let params = parameters {
Leanplum.track(event.rawValue, withParameters: params)
} else {
Leanplum.track(event.rawValue)
}
}
}
func set(attributes: [AnyHashable: Any]) {
guard isLPEnabled() else {
return
}
ensureMainThread {
if !self.isPrivateMode() {
Leanplum.setUserAttributes(attributes)
}
}
}
func set(enabled: Bool) {
// Setting up Test Mode stops sending things to server.
if enabled { start() }
self.enabled = enabled
Leanplum.setTestModeEnabled(!enabled)
}
func isFxAPrePushEnabled() -> Bool {
return AppConstants.MOZ_FXA_LEANPLUM_AB_PUSH_TEST && useFxAPrePush.boolValue()
}
/*
This is used to determine if an app was installed after firefox was installed
*/
private func checkIfAppWasInstalled(key: String, isAppInstalled: Bool, lpEvent: LPEvent) {
// if no key is present. create one and set it.
// if the app is already installed then the flag will set true and the second block will never run
if self.prefs?.boolForKey(key) == nil {
self.prefs?.setBool(isAppInstalled, forKey: key)
}
// on a subsquent launch if the app is installed and the key is false then switch the flag to true
if !(self.prefs?.boolForKey(key) ?? false), isAppInstalled {
self.prefs?.setBool(isAppInstalled, forKey: key)
self.track(event: lpEvent)
}
}
private func canOpenApp(scheme: String) -> Bool {
return URL(string: "\(scheme)://").flatMap { UIApplication.shared.canOpenURL($0) } ?? false
}
private func focusInstalled() -> Bool {
return canOpenApp(scheme: MozillaAppSchemes.focus)
}
private func klarInstalled() -> Bool {
return canOpenApp(scheme: MozillaAppSchemes.focusDE)
}
private func pocketInstalled() -> Bool {
return canOpenApp(scheme: MozillaAppSchemes.pocket)
}
private func mailtoIsDefault() -> Bool {
return (prefs?.stringForKey(PrefsKeys.KeyMailToOption) ?? "mailto:") == "mailto:"
}
private func getSettings() -> LPSettings? {
let bundle = Bundle.main
guard let appId = bundle.object(forInfoDictionaryKey: LPAppIdKey) as? String,
let productionKey = bundle.object(forInfoDictionaryKey: LPProductionKeyKey) as? String,
let developmentKey = bundle.object(forInfoDictionaryKey: LPDevelopmentKeyKey) as? String else {
return nil
}
return LPSettings(appId: appId, developmentKey: developmentKey, productionKey: productionKey)
}
// This must be called before `Leanplum.start` in order to correctly setup
// custom message templates.
private func setupCustomTemplates() {
// These properties are exposed through the Leanplum web interface.
// Ref: https://github.com/Leanplum/Leanplum-iOS-Samples/blob/master/iOS_customMessageTemplates/iOS_customMessageTemplates/LPMessageTemplates.m
let args: [LPActionArg] = [
LPActionArg(named: LPMessage.ArgTitleText, with: LPMessage.DefaultAskToAskTitle),
LPActionArg(named: LPMessage.ArgTitleColor, with: UIColor.black),
LPActionArg(named: LPMessage.ArgMessageText, with: LPMessage.DefaultAskToAskMessage),
LPActionArg(named: LPMessage.ArgMessageColor, with: UIColor.black),
LPActionArg(named: LPMessage.ArgAcceptButtonText, with: LPMessage.DefaultOkButtonText),
LPActionArg(named: LPMessage.ArgCancelAction, withAction: nil),
LPActionArg(named: LPMessage.ArgCancelButtonText, with: LPMessage.DefaultLaterButtonText),
LPActionArg(named: LPMessage.ArgCancelButtonTextColor, with: UIColor.gray)
]
let responder: LeanplumActionBlock = { (context) -> Bool in
// Before proceeding, double check that Leanplum FxA prepush config value has been enabled.
if !self.isFxAPrePushEnabled() {
return false
}
guard let context = context else {
return false
}
// Don't display permission screen if they have already allowed/disabled push permissions
if self.prefs?.boolForKey(AppRequestedUserNotificationsPrefKey) ?? false {
FxALoginHelper.sharedInstance.readyForSyncing()
return false
}
// Present Alert View onto the current top view controller
let rootViewController = UIApplication.topViewController()
let alert = UIAlertController(title: context.stringNamed(LPMessage.ArgTitleText), message: context.stringNamed(LPMessage.ArgMessageText), preferredStyle: .alert)
alert.addAction(UIAlertAction(title: context.stringNamed(LPMessage.ArgCancelButtonText), style: .cancel, handler: { (action) -> Void in
// Log cancel event and call ready for syncing
context.runTrackedActionNamed(LPMessage.ArgCancelAction)
FxALoginHelper.sharedInstance.readyForSyncing()
}))
alert.addAction(UIAlertAction(title: context.stringNamed(LPMessage.ArgAcceptButtonText), style: .default, handler: { (action) -> Void in
// Log accept event and present push permission modal
context.runTrackedActionNamed(LPMessage.ArgAcceptAction)
FxALoginHelper.sharedInstance.requestUserNotifications(UIApplication.shared)
self.prefs?.setBool(true, forKey: AppRequestedUserNotificationsPrefKey)
}))
rootViewController?.present(alert, animated: true, completion: nil)
return true
}
// Register or update the custom Leanplum message
Leanplum.defineAction(LPMessage.FxAPrePush, of: kLeanplumActionKindMessage, withArguments: args, withOptions: [:], withResponder: responder)
}
}
extension UIApplication {
// Extension to get the current top most view controller
class func topViewController(base: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
if let nav = base as? UINavigationController {
return topViewController(base: nav.visibleViewController)
}
if let tab = base as? UITabBarController {
if let selected = tab.selectedViewController {
return topViewController(base: selected)
}
}
if let presented = base?.presentedViewController {
return topViewController(base: presented)
}
return base
}
}