Files
Armchair-UrbanApps/Source/Armchair.swift
weak 51eca2a931 Conversion to Swift 5
- convert all targets to Swift 5
- bump .swift-version
- update podspec
- fix warnings
2019-09-19 10:47:04 +02:00

1874 lines
79 KiB
Swift

// Armchair.swift
//
// Copyright (c) 2014 Armchair (http://github.com/UrbanApps/Armchair)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
import StoreKit
import SystemConfiguration
#if os(iOS)
import UIKit
#elseif os(OSX)
import AppKit
#else
// Not yet supported
#endif
// MARK: -
// MARK: PUBLIC Interface
// MARK: -
// MARK: Properties
/*
* Get/Set your Apple generated software id.
* This is the only required setup value.
* This call needs to be first. No default.
*/
public var appID: String = ""
public func appID(_ appID: String) {
Armchair.appID = appID
Manager.defaultManager.appID = appID
}
/*
* Get/Set the App Name to use in the prompt
* Default value is your localized display name from the info.plist
*/
public func appName() -> String {
return Manager.defaultManager.appName
}
public func appName(_ appName: String) {
Manager.defaultManager.appName = appName
}
/*
* Get/Set the title to use on the review prompt.
* Default value is a localized "Rate <appName>"
*/
public func reviewTitle() -> String {
return Manager.defaultManager.reviewTitle
}
public func reviewTitle(_ reviewTitle: String) {
Manager.defaultManager.reviewTitle = reviewTitle
}
/*
* Get/Set the message to use on the review prompt.
* Default value is a localized
* "If you enjoy using <appName>, would you mind taking a moment to rate it? It won't take more than a minute. Thanks for your support!"
*/
public func reviewMessage() -> String {
return Manager.defaultManager.reviewMessage
}
public func reviewMessage(_ reviewMessage: String) {
Manager.defaultManager.reviewMessage = reviewMessage
}
/*
* Get/Set the cancel button title to use on the review prompt.
* Default value is a localized "No, Thanks"
*/
public func cancelButtonTitle() -> String {
return Manager.defaultManager.cancelButtonTitle
}
public func cancelButtonTitle(_ cancelButtonTitle: String) {
Manager.defaultManager.cancelButtonTitle = cancelButtonTitle
}
/*
* Get/Set the rate button title to use on the review prompt.
* Default value is a localized "Rate <appName>"
*/
public func rateButtonTitle() -> String {
return Manager.defaultManager.rateButtonTitle
}
public func rateButtonTitle(_ rateButtonTitle: String) {
Manager.defaultManager.rateButtonTitle = rateButtonTitle
}
/*
* Get/Set the remind me later button title to use on the review prompt.
* It is optional, so you can set it to nil to hide the remind button from displaying
* Default value is a localized "Remind me later"
*/
public func remindButtonTitle() -> String? {
return Manager.defaultManager.remindButtonTitle
}
public func remindButtonTitle(_ remindButtonTitle: String?) {
Manager.defaultManager.remindButtonTitle = remindButtonTitle
}
/*
* Get/Set the NSUserDefault keys that store the usage data for Armchair
* Default values are in the form of "<appID>_Armchair<Setting>"
*/
public func keyForArmchairKeyType(_ keyType: ArmchairKey) -> String {
return Manager.defaultManager.keyForArmchairKeyType(keyType)
}
public func setKey(_ key: NSString, armchairKeyType: ArmchairKey) {
Manager.defaultManager.setKey(key, armchairKeyType: armchairKeyType)
}
/*
* Get/Set the prefix to the NSUserDefault keys that store the usage data for Armchair
* Default value is the App ID, and it is prepended to the keys for key type, above
* This prevents different apps using a shared Key/Value store from overwriting each other.
*/
public func keyPrefix() -> String {
return Manager.defaultManager.keyPrefix
}
public func keyPrefix(_ keyPrefix: String) {
Manager.defaultManager.keyPrefix = keyPrefix
}
/*
* Get/Set the object that stores the usage data for Armchair
* it is optional but if you pass nil, Armchair can not run.
* Default value is NSUserDefaults.standardUserDefaults()
*/
public func userDefaultsObject() -> ArmchairDefaultsObject? {
return Manager.defaultManager.userDefaultsObject
}
public func userDefaultsObject(_ userDefaultsObject: ArmchairDefaultsObject?) {
Manager.defaultManager.userDefaultsObject = userDefaultsObject
}
/*
* Users will need to have the same version of your app installed for this many
* days before they will be prompted to rate it.
* Default => 30
*/
public func daysUntilPrompt() -> UInt {
return Manager.defaultManager.daysUntilPrompt
}
public func daysUntilPrompt(_ daysUntilPrompt: UInt) {
Manager.defaultManager.daysUntilPrompt = daysUntilPrompt
}
/*
* An example of a 'use' would be if the user launched the app. Bringing the app
* into the foreground (on devices that support it) would also be considered
* a 'use'.
*
* Users need to 'use' the same version of the app this many times before
* before they will be prompted to rate it.
* Default => 20
*/
public func usesUntilPrompt() -> UInt {
return Manager.defaultManager.usesUntilPrompt
}
public func usesUntilPrompt(_ usesUntilPrompt: UInt) {
Manager.defaultManager.usesUntilPrompt = usesUntilPrompt
}
/*
* A significant event can be anything you want to be in your app. In a
* telephone app, a significant event might be placing or receiving a call.
* In a game, it might be beating a level or a boss. This is just another
* layer of filtering that can be used to make sure that only the most
* loyal of your users are being prompted to rate you on the app store.
* If you leave this at a value of 0 (default), then this won't be a criterion
* used for rating.
*
* To tell Armchair that the user has performed
* a significant event, call the method Armchair.userDidSignificantEvent()
* Default => 0
*/
public func significantEventsUntilPrompt() -> UInt {
return Manager.defaultManager.significantEventsUntilPrompt
}
public func significantEventsUntilPrompt(_ significantEventsUntilPrompt: UInt) {
Manager.defaultManager.significantEventsUntilPrompt = significantEventsUntilPrompt
}
/*
* Once the rating alert is presented to the user, they might select
* 'Remind me later'. This value specifies how many days Armchair
* will wait before reminding them. A value of 0 disables reminders and
* removes the 'Remind me later' button.
* Default => 1
*/
public func daysBeforeReminding() -> UInt {
return Manager.defaultManager.daysBeforeReminding
}
public func daysBeforeReminding(_ daysBeforeReminding: UInt) {
Manager.defaultManager.daysBeforeReminding = daysBeforeReminding
}
/*
* By default, Armchair tracks all new bundle versions.
* When it detects a new version, it resets the values saved for usage,
* significant events, popup shown, user action etc...
* By setting this to false, Armchair will ONLY track the version it
* was initialized with. If this setting is set to true, Armchair
* will reset after each new version detection.
* Default => true
*/
public func tracksNewVersions() -> Bool {
return Manager.defaultManager.tracksNewVersions
}
public func tracksNewVersions(_ tracksNewVersions: Bool) {
Manager.defaultManager.tracksNewVersions = tracksNewVersions
}
/*
* If the user has rated the app once before, and you don't want it to show on
* a new version, set this to false. This is useful if you release small bugfix
* versions and don't want to pester your users with popups for every minor
* version. For example, you might set this to false for every minor build, then
* when you push a major version upgrade, leave it as true to ask for a rating again.
* Default => true
*/
public func shouldPromptIfRated() -> Bool {
return Manager.defaultManager.shouldPromptIfRated
}
public func shouldPromptIfRated(_ shouldPromptIfRated: Bool) {
Manager.defaultManager.shouldPromptIfRated = shouldPromptIfRated
}
/*
* Return whether Armchair will try and present the Storekit review prompt (useful for custom dialog modification)
*/
public var shouldTryStoreKitReviewPrompt : Bool {
return Manager.defaultManager.shouldTryStoreKitReviewPrompt
}
/*
* If set to true, the main bundle will always be used to load localized strings.
* Set this to true if you have provided your own custom localizations in
* ArmchairLocalizable.strings in your main bundle
* Default => false.
*/
public func useMainAppBundleForLocalizations() -> Bool {
return Manager.defaultManager.useMainAppBundleForLocalizations
}
public func useMainAppBundleForLocalizations(_ useMainAppBundleForLocalizations: Bool) {
Manager.defaultManager.useMainAppBundleForLocalizations = useMainAppBundleForLocalizations
}
/*
* If you are an Apple Affiliate, enter your code here.
* If none is set, the author's code will be used as it is better to be set as something
* rather than nothing. If you want to thank me for making Armchair, feel free
* to leave this value at it's default.
*/
public func affiliateCode() -> String {
return Manager.defaultManager.affiliateCode
}
public func affiliateCode(_ affiliateCode: String) {
Manager.defaultManager.affiliateCode = affiliateCode
}
/*
* If you are an Apple Affiliate, enter your campaign code here.
* Default => "Armchair-<appID>"
*/
public func affiliateCampaignCode() -> String {
return Manager.defaultManager.affiliateCampaignCode
}
public func affiliateCampaignCode(_ affiliateCampaignCode: String) {
Manager.defaultManager.affiliateCampaignCode = affiliateCampaignCode
}
/*
* If set to true, use SKStoreReviewController's requestReview() prompt instead of the default prompt.
* If not on iOS 10.3+ or macOS 10.4+, resort to the default prompt.
* Default => false.
*/
public func useStoreKitReviewPrompt() -> Bool {
return Manager.defaultManager.useStoreKitReviewPrompt
}
public func useStoreKitReviewPrompt(_ useStoreKitReviewPrompt: Bool) {
Manager.defaultManager.useStoreKitReviewPrompt = useStoreKitReviewPrompt
}
/*
* 'true' will show the Armchair alert everytime. Useful for testing
* how your message looks and making sure the link to your app's review page works.
* Calling this method in a production build (DEBUG preprocessor macro is not defined)
* has no effect. In app store builds, you don't have to worry about accidentally
* leaving debugEnabled to true
* Default => false
*/
public func debugEnabled() -> Bool {
return Manager.defaultManager.debugEnabled
}
public func debugEnabled(_ debugEnabled: Bool) {
#if Debug
Manager.defaultManager.debugEnabled = debugEnabled
#else
print("[Armchair] Debug is disabled on release builds.")
print("[Armchair] If you really want to enable debug mode,")
print("[Armchair] add \"-DDebug\" to your Swift Compiler - Custom Flags")
print("[Armchair] section in the target's build settings for release")
#endif
}
/**
Reset all counters manually. This resets UseCount, SignificantEventCount and FirstUseDate (daysUntilPrompt)
*/
public func resetUsageCounters() {
StandardUserDefaults().setObject(NSNumber(value: Date().timeIntervalSince1970), forKey: keyForArmchairKeyType(ArmchairKey.FirstUseDate))
StandardUserDefaults().setObject(NSNumber(value: 1), forKey: keyForArmchairKeyType(ArmchairKey.UseCount))
StandardUserDefaults().setObject(NSNumber(value: 0), forKey: keyForArmchairKeyType(ArmchairKey.SignificantEventCount))
StandardUserDefaults().synchronize()
}
/**
Reset all values tracked by Armchair to initial state.
*/
public func resetAllCounters() {
let currentVersionKey = keyForArmchairKeyType(ArmchairKey.CurrentVersion)
let trackingVersion: String? = StandardUserDefaults().stringForKey(currentVersionKey)
let bundleVersionKey = kCFBundleVersionKey as String
let currentVersion = Bundle.main.object(forInfoDictionaryKey: bundleVersionKey) as? String
StandardUserDefaults().setObject(trackingVersion as AnyObject?, forKey: keyForArmchairKeyType(ArmchairKey.PreviousVersion))
StandardUserDefaults().setObject(StandardUserDefaults().objectForKey(keyForArmchairKeyType(ArmchairKey.RatedCurrentVersion)), forKey: keyForArmchairKeyType(ArmchairKey.PreviousVersionRated))
StandardUserDefaults().setObject(StandardUserDefaults().objectForKey(keyForArmchairKeyType(ArmchairKey.DeclinedToRate)), forKey: keyForArmchairKeyType(ArmchairKey.PreviousVersionDeclinedToRate))
StandardUserDefaults().setObject(currentVersion as AnyObject?, forKey: currentVersionKey)
resetUsageCounters()
StandardUserDefaults().setObject(NSNumber(value: false), forKey: keyForArmchairKeyType(ArmchairKey.RatedCurrentVersion))
StandardUserDefaults().setObject(NSNumber(value: false), forKey: keyForArmchairKeyType(ArmchairKey.DeclinedToRate))
StandardUserDefaults().setObject(NSNumber(value: 0), forKey: keyForArmchairKeyType(ArmchairKey.ReminderRequestDate))
StandardUserDefaults().synchronize()
}
/*
*
*
*/
public func resetDefaults() {
Manager.defaultManager.debugEnabled = false
Manager.defaultManager.appName = Manager.defaultManager.defaultAppName()
Manager.defaultManager.reviewTitle = Manager.defaultManager.defaultReviewTitle()
Manager.defaultManager.reviewMessage = Manager.defaultManager.defaultReviewMessage()
Manager.defaultManager.cancelButtonTitle = Manager.defaultManager.defaultCancelButtonTitle()
Manager.defaultManager.rateButtonTitle = Manager.defaultManager.defaultRateButtonTitle()
Manager.defaultManager.remindButtonTitle = Manager.defaultManager.defaultRemindButtonTitle()
Manager.defaultManager.daysUntilPrompt = 30
Manager.defaultManager.daysBeforeReminding = 1
Manager.defaultManager.shouldPromptIfRated = true
Manager.defaultManager.significantEventsUntilPrompt = 20
Manager.defaultManager.tracksNewVersions = true
Manager.defaultManager.useMainAppBundleForLocalizations = false
Manager.defaultManager.affiliateCode = Manager.defaultManager.defaultAffiliateCode()
Manager.defaultManager.affiliateCampaignCode = Manager.defaultManager.defaultAffiliateCampaignCode()
Manager.defaultManager.didDeclineToRateClosure = nil
Manager.defaultManager.didDisplayAlertClosure = nil
Manager.defaultManager.didOptToRateClosure = nil
Manager.defaultManager.didOptToRemindLaterClosure = nil
Manager.defaultManager.customAlertClosure = nil
#if os(iOS)
Manager.defaultManager.usesAnimation = true
Manager.defaultManager.tintColor = nil
Manager.defaultManager.usesAlertController = Manager.defaultManager.defaultUsesAlertController()
Manager.defaultManager.opensInStoreKit = Manager.defaultManager.defaultOpensInStoreKit()
Manager.defaultManager.willPresentModalViewClosure = nil
Manager.defaultManager.didDismissModalViewClosure = nil
#endif
Manager.defaultManager.armchairKeyFirstUseDate = Manager.defaultManager.defaultArmchairKeyFirstUseDate()
Manager.defaultManager.armchairKeyUseCount = Manager.defaultManager.defaultArmchairKeyUseCount()
Manager.defaultManager.armchairKeySignificantEventCount = Manager.defaultManager.defaultArmchairKeySignificantEventCount()
Manager.defaultManager.armchairKeyCurrentVersion = Manager.defaultManager.defaultArmchairKeyCurrentVersion()
Manager.defaultManager.armchairKeyRatedCurrentVersion = Manager.defaultManager.defaultArmchairKeyRatedCurrentVersion()
Manager.defaultManager.armchairKeyDeclinedToRate = Manager.defaultManager.defaultArmchairKeyDeclinedToRate()
Manager.defaultManager.armchairKeyReminderRequestDate = Manager.defaultManager.defaultArmchairKeyReminderRequestDate()
Manager.defaultManager.armchairKeyPreviousVersion = Manager.defaultManager.defaultArmchairKeyPreviousVersion()
Manager.defaultManager.armchairKeyPreviousVersionRated = Manager.defaultManager.defaultArmchairKeyPreviousVersionRated()
Manager.defaultManager.armchairKeyPreviousVersionDeclinedToRate = Manager.defaultManager.defaultArmchairKeyDeclinedToRate()
Manager.defaultManager.armchairKeyRatedAnyVersion = Manager.defaultManager.defaultArmchairKeyRatedAnyVersion()
Manager.defaultManager.armchairKeyAppiraterMigrationCompleted = Manager.defaultManager.defaultArmchairKeyAppiraterMigrationCompleted()
Manager.defaultManager.armchairKeyUAAppReviewManagerMigrationCompleted = Manager.defaultManager.defaultArmchairKeyUAAppReviewManagerMigrationCompleted()
Manager.defaultManager.keyPrefix = Manager.defaultManager.defaultKeyPrefix()
}
#if os(iOS)
/*
* Set whether or not Armchair uses animation when pushing modal StoreKit
* view controllers for the app.
* Default => true
*/
public func usesAnimation() -> Bool {
return Manager.defaultManager.usesAnimation
}
public func usesAnimation(_ usesAnimation: Bool) {
Manager.defaultManager.usesAnimation = usesAnimation
}
/*
* Set a tint color to apply to UIAlertController
* Default => nil (the default tint color is used)
*/
public func tintColor() -> UIColor? {
return Manager.defaultManager.tintColor
}
public func tintColor(tintColor: UIColor?) {
Manager.defaultManager.tintColor = tintColor
}
/*
* Set whether or not Armchair uses a UIAlertController when presenting on iOS 8
* We prefer not to use it so that the Rate button can be on the bottom and the cancel button on the top,
* Something not possible as of iOS 8.0
* Default => false
*/
public func usesAlertController() -> Bool {
return Manager.defaultManager.usesAlertController
}
public func usesAlertController(_ usesAlertController: Bool) {
Manager.defaultManager.usesAlertController = usesAlertController
}
/*
* If set to true, Armchair will open App Store link inside the app using
* SKStoreProductViewController.
* - itunes affiliate codes DO NOT work on iOS 7 inside StoreKit,
* - itunes affiliate codes DO work on iOS 8 inside StoreKit,
* Default => false on iOS 7, true on iOS 8+
*/
public func opensInStoreKit() -> Bool {
return Manager.defaultManager.opensInStoreKit
}
public func opensInStoreKit(_ opensInStoreKit: Bool) {
Manager.defaultManager.opensInStoreKit = opensInStoreKit
}
#endif
// MARK: Events
/*
* Tells Armchair that the user performed a significant event.
* A significant event is whatever you want it to be. If you're app is used
* to make VoIP calls, then you might want to call this method whenever the
* user places a call. If it's a game, you might want to call this whenever
* the user beats a level boss.
*
* If the user has performed enough significant events and used the app enough,
* you can suppress the rating alert by passing false for canPromptForRating. The
* rating alert will simply be postponed until it is called again with true for
* canPromptForRating.
*/
public func userDidSignificantEvent(_ canPromptForRating: Bool) {
Manager.defaultManager.userDidSignificantEvent(canPromptForRating)
}
/*
* Tells Armchair that the user performed a significant event.
* A significant event is whatever you want it to be. If you're app is used
* to make VoIP calls, then you might want to call this method whenever the
* user places a call. If it's a game, you might want to call this whenever
* the user beats a level boss.
*
* This is similar to the userDidSignificantEvent method, but allows the passing of a
* ArmchairShouldPromptClosure that will be executed before prompting.
* The closure passes all the keys and values that Armchair uses to
* determine if it the prompt conditions have been met, and it is up to you
* to use this info and return a Bool on whether or not the prompt should be shown.
* The closure is run synchronous and on the main queue, so be sure to handle it appropriately.
* Return true to proceed and show the prompt, return false to kill the pending presentation.
*/
public func userDidSignificantEvent(_ shouldPrompt: @escaping ArmchairShouldPromptClosure) {
Manager.defaultManager.userDidSignificantEvent(shouldPrompt)
}
// MARK: Prompts
/*
* Tells Armchair to show the prompt (a rating alert). The prompt
* will be showed if there is an internet connection available, the user hasn't
* declined to rate, hasn't rated current version and you are tracking new versions.
*
* You could call to show the prompt regardless of Armchair settings,
* for instance, in the case of some special event in your app.
*/
public func showPrompt() {
Manager.defaultManager.showPrompt()
}
/*
* Tells Armchair to show the review prompt alert if all restrictions have been met.
* The prompt will be shown if all restrictions are met, there is an internet connection available,
* the user hasn't declined to rate, hasn't rated current version, and you are tracking new versions.
*
* You could call to show the prompt, for instance, in the case of some special event in your app,
* like a user login.
*/
public func showPromptIfNecessary() {
Manager.defaultManager.showPrompt(ifNecessary: true)
}
/*
* Tells Armchair to show the review prompt alert.
*
* This is similar to the showPromptIfNecessary method, but allows the passing of a
* ArmchairShouldPromptClosure that will be executed before prompting.
* The closure passes all the keys and values that Armchair uses to
* determine if it the prompt conditions have been met, and it is up to you
* to use this info and return a Bool on whether or not the prompt should be shown.
* The closure is run synchronous and on the main queue, so be sure to handle it appropriately.
* Return true to proceed and show the prompt, return false to kill the pending presentation.
*/
public func showPrompt(_ shouldPrompt: ArmchairShouldPromptClosure) {
Manager.defaultManager.showPrompt(shouldPrompt)
}
/**
Returns true if rating conditions have been met already and rating prompt is likely to be shown.
*/
public func ratingConditionsHaveBeenMet() -> Bool {
return Manager.defaultManager.ratingConditionsHaveBeenMet()
}
// MARK: Misc
/*
* This is the review URL string, generated by substituting the appID, affiliate code
* and affilitate campaign code into the template URL.
*/
public func reviewURLString() -> String {
return Manager.defaultManager.reviewURLString()
}
/*
* Tells Armchair to open the App Store page where the user can specify a
* rating for the app. Also records the fact that this has happened, so the
* user won't be prompted again to rate the app.
*
* The only case where you should call this directly is if your app has an
* explicit "Rate this app" command somewhere. In all other cases, don't worry
* about calling this -- instead, just call the other functions listed above,
* and let Armchair handle the bookkeeping of deciding when to ask the user
* whether to rate the app.
*/
public func rateApp() {
Manager.defaultManager.rateApp()
}
#if os(iOS)
/*
* Tells Armchair to immediately close any open rating modals
* for instance, a StoreKit rating View Controller.
*/
public func closeModalPanel() {
Manager.defaultManager.closeModalPanel()
}
#endif
// MARK: Closures
/*
* Armchair uses closures instead of delegate methods for callbacks.
* Default is nil for all of them.
*/
public typealias ArmchairClosure = () -> ()
public typealias ArmchairClosureCustomAlert = (_ rateAppClosure: @escaping ArmchairClosure, _ remindLaterClosure: @escaping ArmchairClosure, _ noThanksClosure: @escaping ArmchairClosure) -> ()
public typealias ArmchairAnimateClosure = (Bool) -> ()
public typealias ArmchairShouldPromptClosure = (ArmchairTrackingInfo) -> Bool
public typealias ArmchairShouldIncrementClosure = () -> Bool
public func onDidDisplayAlert(_ didDisplayAlertClosure: ArmchairClosure?) {
Manager.defaultManager.didDisplayAlertClosure = didDisplayAlertClosure
}
public func customAlertClosure(_ customAlertClosure: ArmchairClosureCustomAlert?) {
Manager.defaultManager.customAlertClosure = customAlertClosure
}
public func onDidDeclineToRate(_ didDeclineToRateClosure: ArmchairClosure?) {
Manager.defaultManager.didDeclineToRateClosure = didDeclineToRateClosure
}
public func onDidOptToRate(_ didOptToRateClosure: ArmchairClosure?) {
Manager.defaultManager.didOptToRateClosure = didOptToRateClosure
}
public func onDidOptToRemindLater(_ didOptToRemindLaterClosure: ArmchairClosure?) {
Manager.defaultManager.didOptToRemindLaterClosure = didOptToRemindLaterClosure
}
#if os(iOS)
public func onWillPresentModalView(_ willPresentModalViewClosure: ArmchairAnimateClosure?) {
Manager.defaultManager.willPresentModalViewClosure = willPresentModalViewClosure
}
public func onDidDismissModalView(_ didDismissModalViewClosure: ArmchairAnimateClosure?) {
Manager.defaultManager.didDismissModalViewClosure = didDismissModalViewClosure
}
#endif
/*
* The setShouldPromptClosure is called just after all the rating coditions
* have been met and Armchair has decided it should display a prompt,
* but just before the prompt actually displays.
*
* The closure passes all the keys and values that Armchair used to
* determine that the prompt conditions had been met, but it is up to you
* to use this info and return a Bool on whether or not the prompt should be shown.
* Return true to proceed and show the prompt, return false to kill the pending presentation.
*/
public func shouldPromptClosure(_ shouldPromptClosure: ArmchairShouldPromptClosure?) {
Manager.defaultManager.shouldPromptClosure = shouldPromptClosure
}
/*
* The setShouldIncrementUseClosure, if valid, is called before incrementing the use count.
* Returning false allows you to ignore a use. This may be usefull in cases such as Facebook login
* where the app is backgrounded momentarily and the resultant enter foreground event should
* not be considered another use.
*/
public func shouldIncrementUseCountClosure(_ shouldIncrementUseCountClosure: ArmchairShouldIncrementClosure?) {
Manager.defaultManager.shouldIncrementUseCountClosure = shouldIncrementUseCountClosure
}
// MARK: Armchair Logger Protocol
public typealias ArmchairLogger = (Manager, _ log: String, _ file: StaticString, _ function: StaticString, _ line: UInt) -> Void
/*
* Set a closure to capture debug log and to plug in the desired logging framework.
*/
public func logger(_ logger: @escaping ArmchairLogger) {
Manager.defaultManager.logger = logger
}
// MARK: -
// MARK: Armchair Defaults Protocol
@objc public protocol ArmchairDefaultsObject {
func objectForKey(_ defaultName: String) -> AnyObject?
func setObject(_ value: AnyObject?, forKey defaultName: String)
func removeObjectForKey(_ defaultName: String)
func stringForKey(_ defaultName: String) -> String?
func integerForKey(_ defaultName: String) -> Int
func doubleForKey(_ defaultName: String) -> Double
func boolForKey(_ defaultName: String) -> Bool
func setInteger(_ value: Int, forKey defaultName: String)
func setDouble(_ value: Double, forKey defaultName: String)
func setBool(_ value: Bool, forKey defaultName: String)
@discardableResult
func synchronize() -> Bool
}
open class StandardUserDefaults: ArmchairDefaultsObject {
let defaults = UserDefaults.standard
@objc open func objectForKey(_ defaultName: String) -> AnyObject? { return defaults.object(forKey: defaultName) as AnyObject? }
@objc open func setObject(_ value: AnyObject?, forKey defaultName: String) { defaults.set(value, forKey: defaultName) }
@objc open func removeObjectForKey(_ defaultName: String) { defaults.removeObject(forKey: defaultName) }
@objc open func stringForKey(_ defaultName: String) -> String? { return defaults.string(forKey: defaultName) }
@objc open func integerForKey(_ defaultName: String) -> Int { return defaults.integer(forKey: defaultName) }
@objc open func doubleForKey(_ defaultName: String) -> Double { return defaults.double(forKey: defaultName) }
@objc open func boolForKey(_ defaultName: String) -> Bool { return defaults.bool(forKey: defaultName) }
@objc open func setInteger(_ value: Int, forKey defaultName: String) { defaults.set(value, forKey: defaultName) }
@objc open func setDouble(_ value: Double, forKey defaultName: String) { defaults.set(value, forKey: defaultName) }
@objc open func setBool(_ value: Bool, forKey defaultName: String) { defaults.set(value, forKey: defaultName) }
@discardableResult
@objc open func synchronize() -> Bool { return defaults.synchronize() }
}
public enum ArmchairKey: String, CustomStringConvertible {
case FirstUseDate = "First Use Date"
case UseCount = "Use Count"
case SignificantEventCount = "Significant Event Count"
case CurrentVersion = "Current Version"
case RatedCurrentVersion = "Rated Current Version"
case DeclinedToRate = "Declined To Rate"
case ReminderRequestDate = "Reminder Request Date"
case PreviousVersion = "Previous Version"
case PreviousVersionRated = "Previous Version Rated"
case PreviousVersionDeclinedToRate = "Previous Version Declined To Rate"
case RatedAnyVersion = "Rated Any Version"
case AppiraterMigrationCompleted = "Appirater Migration Completed"
case UAAppReviewManagerMigrationCompleted = "UAAppReviewManager Migration Completed"
static let allValues = [FirstUseDate, UseCount, SignificantEventCount, CurrentVersion, RatedCurrentVersion, DeclinedToRate, ReminderRequestDate, PreviousVersion, PreviousVersionRated, PreviousVersionDeclinedToRate, RatedAnyVersion, AppiraterMigrationCompleted, UAAppReviewManagerMigrationCompleted]
public var description : String {
get {
return self.rawValue
}
}
}
open class ArmchairTrackingInfo: CustomStringConvertible {
public let info: Dictionary<ArmchairKey, AnyObject>
init(info: Dictionary<ArmchairKey, AnyObject>) {
self.info = info
}
open var description: String {
get {
var description = "ArmchairTrackingInfo\r"
for (key, val) in info {
description += " - \(key): \(val)\r"
}
return description
}
}
}
public struct AppiraterKey {
static var FirstUseDate = "kAppiraterFirstUseDate"
static var UseCount = "kAppiraterUseCount"
static var SignificantEventCount = "kAppiraterSignificantEventCount"
static var CurrentVersion = "kAppiraterCurrentVersion"
static var RatedCurrentVersion = "kAppiraterRatedCurrentVersion"
static var RatedAnyVersion = "kAppiraterRatedAnyVersion"
static var DeclinedToRate = "kAppiraterDeclinedToRate"
static var ReminderRequestDate = "kAppiraterReminderRequestDate"
}
// MARK: -
// MARK: PRIVATE Interface
#if os(iOS)
open class ArmchairManager : NSObject, SKStoreProductViewControllerDelegate { }
#elseif os(OSX)
open class ArmchairManager : NSObject, NSAlertDelegate { }
#else
// Untested, and currently unsupported
#endif
open class Manager : ArmchairManager {
#if os(iOS)
fileprivate var operatingSystemVersion = NSString(string: UIDevice.current.systemVersion).doubleValue
#elseif os(OSX)
private var operatingSystemVersion = Double(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)
#else
#endif
// MARK: -
// MARK: Review Alert & Properties
#if os(iOS)
fileprivate var ratingAlert: UIAlertController? = nil
fileprivate let reviewURLTemplate = "itms-apps://itunes.apple.com/WebObjects/MZStore.woa/wa/viewContentsUserReviews?type=Purple+Software&onlyLatestVersion=true&pageNumber=0&sortOrdering=1&id=APP_ID&at=AFFILIATE_CODE&ct=AFFILIATE_CAMPAIGN_CODE&action=write-review"
fileprivate let reviewURLTemplateiOS11 = "https://itunes.apple.com/us/app/idAPP_ID?ls=1&mt=8&at=AFFILIATE_CODE&ct=AFFILIATE_CAMPAIGN_CODE&action=write-review"
#elseif os(OSX)
private var ratingAlert: NSAlert? = nil
private let reviewURLTemplate = "macappstore://itunes.apple.com/us/app/idAPP_ID?ls=1&mt=12&at=AFFILIATE_CODE&ct=AFFILIATE_CAMPAIGN_CODE"
#else
#endif
fileprivate lazy var appName: String = self.defaultAppName()
fileprivate func defaultAppName() -> String {
let mainBundle = Bundle.main
let displayName = mainBundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
let bundleNameKey = kCFBundleNameKey as String
let name = mainBundle.object(forInfoDictionaryKey: bundleNameKey) as? String
return displayName ?? name ?? "This App"
}
fileprivate lazy var reviewTitle: String = self.defaultReviewTitle()
fileprivate func defaultReviewTitle() -> String {
var template = "Rate %@"
// Check for a localized version of the default title
if let bundle = self.bundle() {
template = bundle.localizedString(forKey: template,
value: bundle.localizedString(forKey: template, value:"", table: nil),
table: "ArmchairLocalizable")
}
return template.replacingOccurrences(of: "%@", with: "\(self.appName)", options: NSString.CompareOptions(rawValue: 0), range: nil)
}
fileprivate lazy var reviewMessage: String = self.defaultReviewMessage()
fileprivate func defaultReviewMessage() -> String {
var template = "If you enjoy using %@, would you mind taking a moment to rate it? It won't take more than a minute. Thanks for your support!"
// Check for a localized version of the default title
if let bundle = self.bundle() {
template = bundle.localizedString(forKey: template,
value: bundle.localizedString(forKey: template, value:"", table: nil),
table: "ArmchairLocalizable")
}
return template.replacingOccurrences(of: "%@", with: "\(self.appName)", options: NSString.CompareOptions(rawValue: 0), range: nil)
}
fileprivate lazy var cancelButtonTitle: String = self.defaultCancelButtonTitle()
fileprivate func defaultCancelButtonTitle() -> String {
var title = "No, Thanks"
// Check for a localized version of the default title
if let bundle = self.bundle() {
title = bundle.localizedString(forKey: title,
value: bundle.localizedString(forKey: title, value:"", table: nil),
table: "ArmchairLocalizable")
}
return title
}
fileprivate lazy var rateButtonTitle: String = self.defaultRateButtonTitle()
fileprivate func defaultRateButtonTitle() -> String {
var template = "Rate %@"
// Check for a localized version of the default title
if let bundle = self.bundle() {
template = bundle.localizedString(forKey: template,
value: bundle.localizedString(forKey: template, value:"", table: nil),
table: "ArmchairLocalizable")
}
return template.replacingOccurrences(of: "%@", with: "\(self.appName)", options: NSString.CompareOptions(rawValue: 0), range: nil)
}
fileprivate lazy var remindButtonTitle: String? = self.defaultRemindButtonTitle()
fileprivate func defaultRemindButtonTitle() -> String? {
//if reminders are disabled, return a nil title to supress the button
if self.daysBeforeReminding == 0 {
return nil
}
var title = "Remind me later"
// Check for a localized version of the default title
if let bundle = self.bundle() {
title = bundle.localizedString(forKey: title,
value: bundle.localizedString(forKey: title, value:"", table: nil),
table: "ArmchairLocalizable")
}
return title
}
// Tracking Logic / Configuration
fileprivate var appID: String = "" {
didSet {
keyPrefix = defaultKeyPrefix()
if affiliateCampaignCode == defaultAffiliateCampaignCode() {
affiliateCampaignCode = affiliateCampaignCode + "-\(appID)"
}
}
}
// MARK: Properties with sensible defaults
fileprivate var daysUntilPrompt: UInt = 30
fileprivate var usesUntilPrompt: UInt = 20
fileprivate var significantEventsUntilPrompt: UInt = 0
fileprivate var daysBeforeReminding: UInt = 1
fileprivate var tracksNewVersions: Bool = true
fileprivate var shouldPromptIfRated: Bool = true
fileprivate var useMainAppBundleForLocalizations: Bool = false
fileprivate var debugEnabled: Bool = false {
didSet {
if self.debugEnabled {
debugLog("Debug enabled for app: \(appID)")
}
}
}
// If you aren't going to set an affiliate code yourself, please leave this as is.
// It is my affiliate code. It is better that somebody's code is used rather than nobody's.
fileprivate var affiliateCode: String = "11l7j9"
fileprivate var affiliateCampaignCode: String = "Armchair"
fileprivate var useStoreKitReviewPrompt: Bool = false
#if os(iOS)
fileprivate var usesAnimation: Bool = true
fileprivate var tintColor: UIColor? = nil
fileprivate lazy var usesAlertController: Bool = self.defaultUsesAlertController()
fileprivate lazy var opensInStoreKit: Bool = self.defaultOpensInStoreKit()
fileprivate func defaultOpensInStoreKit() -> Bool {
return operatingSystemVersion >= 8
}
fileprivate func defaultUsesAlertController() -> Bool {
return operatingSystemVersion >= 9
}
#endif
// MARK: Tracking Keys with sensible defaults
fileprivate lazy var armchairKeyFirstUseDate: String = self.defaultArmchairKeyFirstUseDate()
fileprivate lazy var armchairKeyUseCount: String = self.defaultArmchairKeyUseCount()
fileprivate lazy var armchairKeySignificantEventCount: String = self.defaultArmchairKeySignificantEventCount()
fileprivate lazy var armchairKeyCurrentVersion: String = self.defaultArmchairKeyCurrentVersion()
fileprivate lazy var armchairKeyRatedCurrentVersion: String = self.defaultArmchairKeyRatedCurrentVersion()
fileprivate lazy var armchairKeyDeclinedToRate: String = self.defaultArmchairKeyDeclinedToRate()
fileprivate lazy var armchairKeyReminderRequestDate: String = self.defaultArmchairKeyReminderRequestDate()
fileprivate lazy var armchairKeyPreviousVersion: String = self.defaultArmchairKeyPreviousVersion()
fileprivate lazy var armchairKeyPreviousVersionRated: String = self.defaultArmchairKeyPreviousVersionRated()
fileprivate lazy var armchairKeyPreviousVersionDeclinedToRate: String = self.defaultArmchairKeyPreviousVersionDeclinedToRate()
fileprivate lazy var armchairKeyRatedAnyVersion: String = self.defaultArmchairKeyRatedAnyVersion()
fileprivate lazy var armchairKeyAppiraterMigrationCompleted: String = self.defaultArmchairKeyAppiraterMigrationCompleted()
fileprivate lazy var armchairKeyUAAppReviewManagerMigrationCompleted: String = self.defaultArmchairKeyUAAppReviewManagerMigrationCompleted()
fileprivate func defaultArmchairKeyFirstUseDate() -> String { return "ArmchairFirstUseDate" }
fileprivate func defaultArmchairKeyUseCount() -> String { return "ArmchairUseCount" }
fileprivate func defaultArmchairKeySignificantEventCount() -> String { return "ArmchairSignificantEventCount" }
fileprivate func defaultArmchairKeyCurrentVersion() -> String { return "ArmchairKeyCurrentVersion" }
fileprivate func defaultArmchairKeyRatedCurrentVersion() -> String { return "ArmchairRatedCurrentVersion" }
fileprivate func defaultArmchairKeyDeclinedToRate() -> String { return "ArmchairKeyDeclinedToRate" }
fileprivate func defaultArmchairKeyReminderRequestDate() -> String { return "ArmchairReminderRequestDate" }
fileprivate func defaultArmchairKeyPreviousVersion() -> String { return "ArmchairPreviousVersion" }
fileprivate func defaultArmchairKeyPreviousVersionRated() -> String { return "ArmchairPreviousVersionRated" }
fileprivate func defaultArmchairKeyPreviousVersionDeclinedToRate() -> String { return "ArmchairPreviousVersionDeclinedToRate" }
fileprivate func defaultArmchairKeyRatedAnyVersion() -> String { return "ArmchairKeyRatedAnyVersion" }
fileprivate func defaultArmchairKeyAppiraterMigrationCompleted() -> String { return "ArmchairAppiraterMigrationCompleted" }
fileprivate func defaultArmchairKeyUAAppReviewManagerMigrationCompleted() -> String { return "ArmchairUAAppReviewManagerMigrationCompleted" }
fileprivate lazy var keyPrefix: String = self.defaultKeyPrefix()
fileprivate func defaultKeyPrefix() -> String {
if !self.appID.isEmpty {
return self.appID + "_"
} else {
return "_"
}
}
fileprivate var userDefaultsObject:ArmchairDefaultsObject? = StandardUserDefaults()
// MARK: Optional Closures
var didDisplayAlertClosure: ArmchairClosure?
var didDeclineToRateClosure: ArmchairClosure?
var didOptToRateClosure: ArmchairClosure?
var didOptToRemindLaterClosure: ArmchairClosure?
var customAlertClosure: ArmchairClosureCustomAlert?
#if os(iOS)
var willPresentModalViewClosure: ArmchairAnimateClosure?
var didDismissModalViewClosure: ArmchairAnimateClosure?
#endif
var shouldPromptClosure: ArmchairShouldPromptClosure?
var shouldIncrementUseCountClosure: ArmchairShouldIncrementClosure?
// MARK: State Vars
fileprivate var modalPanelOpen: Bool = false
#if os(iOS)
fileprivate lazy var currentStatusBarStyle: UIStatusBarStyle = {
return UIApplication.shared.statusBarStyle
}()
#endif
// MARK: -
// MARK: PRIVATE Methods
fileprivate func userDidSignificantEvent(_ canPromptForRating: Bool) {
DispatchQueue.global(qos: .background).async {
self.incrementSignificantEventAndRate(canPromptForRating)
}
}
fileprivate func userDidSignificantEvent(_ shouldPrompt: @escaping ArmchairShouldPromptClosure) {
DispatchQueue.global(qos: .background).async {
self.incrementSignificantEventAndRate(shouldPrompt)
}
}
// MARK: -
// MARK: PRIVATE Rating Helpers
fileprivate func incrementAndRate(_ canPromptForRating: Bool) {
migrateKeysIfNecessary()
incrementUseCount()
showPrompt(ifNecessary: canPromptForRating)
}
fileprivate func incrementAndRate(_ shouldPrompt: ArmchairShouldPromptClosure) {
migrateKeysIfNecessary()
incrementUseCount()
showPrompt(shouldPrompt)
}
fileprivate func incrementSignificantEventAndRate(_ canPromptForRating: Bool) {
migrateKeysIfNecessary()
incrementSignificantEventCount()
showPrompt(ifNecessary: canPromptForRating)
}
fileprivate func incrementSignificantEventAndRate(_ shouldPrompt: ArmchairShouldPromptClosure) {
migrateKeysIfNecessary()
incrementSignificantEventCount()
showPrompt(shouldPrompt)
}
fileprivate func incrementUseCount() {
var shouldIncrement = true
if let closure = shouldIncrementUseCountClosure {
shouldIncrement = closure()
}
if shouldIncrement {
_incrementCountForKeyType(ArmchairKey.UseCount)
}
}
fileprivate func incrementSignificantEventCount() {
_incrementCountForKeyType(ArmchairKey.SignificantEventCount)
}
fileprivate func _incrementCountForKeyType(_ incrementKeyType: ArmchairKey) {
let incrementKey = keyForArmchairKeyType(incrementKeyType)
let bundleVersionKey = kCFBundleVersionKey as String
// App's version. Not settable as the other ivars because that would be crazy.
let currentVersion = Bundle.main.object(forInfoDictionaryKey: bundleVersionKey) as? String
if currentVersion == nil {
assertionFailure("Could not read kCFBundleVersionKey from InfoDictionary")
return
}
// Get the version number that we've been tracking thus far
let currentVersionKey = keyForArmchairKeyType(ArmchairKey.CurrentVersion)
var trackingVersion: String? = userDefaultsObject?.stringForKey(currentVersionKey)
// New install, or changed keys
if trackingVersion == nil {
trackingVersion = currentVersion
userDefaultsObject?.setObject(currentVersion as AnyObject?, forKey: currentVersionKey)
}
debugLog("Tracking version: \(trackingVersion!)")
if trackingVersion == currentVersion {
// Check if the first use date has been set. if not, set it.
let firstUseDateKey = keyForArmchairKeyType(ArmchairKey.FirstUseDate)
var timeInterval: Double? = userDefaultsObject?.doubleForKey(firstUseDateKey)
if 0 == timeInterval {
timeInterval = Date().timeIntervalSince1970
userDefaultsObject?.setObject(NSNumber(value: timeInterval!), forKey: firstUseDateKey)
}
// Increment the key's count
var incrementKeyCount = userDefaultsObject!.integerForKey(incrementKey)
incrementKeyCount += 1
userDefaultsObject?.setInteger(incrementKeyCount, forKey:incrementKey)
debugLog("Incremented \(incrementKeyType): \(incrementKeyCount)")
} else if tracksNewVersions {
// it's a new version of the app, so restart tracking
resetAllCounters()
debugLog("Reset Tracking Version to: \(trackingVersion!)")
}
userDefaultsObject?.synchronize()
}
fileprivate func showPrompt(ifNecessary canPromptForRating: Bool) {
if canPromptForRating && connectedToNetwork() && ratingConditionsHaveBeenMet() {
var shouldPrompt: Bool = true
if let closure = shouldPromptClosure {
if Thread.isMainThread {
shouldPrompt = closure(trackingInfo())
} else {
DispatchQueue.main.sync {
shouldPrompt = closure(self.trackingInfo())
}
}
}
if shouldPrompt {
DispatchQueue.main.async {
self.showRatingAlert()
}
}
}
}
fileprivate func showPrompt(_ shouldPrompt: ArmchairShouldPromptClosure) {
var shouldPromptVal = false
if Thread.isMainThread {
shouldPromptVal = shouldPrompt(trackingInfo())
} else {
DispatchQueue.main.sync {
shouldPromptVal = shouldPrompt(self.trackingInfo())
}
}
if (shouldPromptVal) {
DispatchQueue.main.async {
self.showRatingAlert()
}
}
}
fileprivate func showPrompt() {
if !appID.isEmpty && connectedToNetwork() && !userHasDeclinedToRate() && !userHasRatedCurrentVersion() {
showRatingAlert()
}
}
fileprivate func ratingConditionsHaveBeenMet() -> Bool {
if debugEnabled {
return true
}
if appID.isEmpty {
return false
}
// check if the app has been used long enough
let timeIntervalOfFirstLaunch = userDefaultsObject?.doubleForKey(keyForArmchairKeyType(ArmchairKey.FirstUseDate))
if let timeInterval = timeIntervalOfFirstLaunch {
let dateOfFirstLaunch = Date(timeIntervalSince1970: timeInterval)
let timeSinceFirstLaunch = Date().timeIntervalSince(dateOfFirstLaunch)
let timeUntilRate: TimeInterval = 60 * 60 * 24 * Double(daysUntilPrompt)
if timeSinceFirstLaunch < timeUntilRate {
return false
}
} else {
return false
}
// check if the app has been used enough times
let useCount = userDefaultsObject?.integerForKey(keyForArmchairKeyType(ArmchairKey.UseCount))
if let count = useCount {
if UInt(count) <= usesUntilPrompt {
return false
}
} else {
return false
}
// check if the user has done enough significant events
let significantEventCount = userDefaultsObject?.integerForKey(keyForArmchairKeyType(ArmchairKey.SignificantEventCount))
if let count = significantEventCount {
if UInt(count) < significantEventsUntilPrompt {
return false
}
} else {
return false
}
// Check if the user previously has declined to rate this version of the app
if userHasDeclinedToRate() {
return false
}
// Check if the user has already rated the app?
if userHasRatedCurrentVersion() {
return false
}
// If the user wanted to be reminded later, has enough time passed?
let timeIntervalOfReminder = userDefaultsObject?.doubleForKey(keyForArmchairKeyType(ArmchairKey.ReminderRequestDate))
if let timeInterval = timeIntervalOfReminder {
let reminderRequestDate = Date(timeIntervalSince1970: timeInterval)
let timeSinceReminderRequest = Date().timeIntervalSince(reminderRequestDate)
let timeUntilReminder: TimeInterval = 60 * 60 * 24 * Double(daysBeforeReminding)
if timeSinceReminderRequest < timeUntilReminder {
return false
}
} else {
return false
}
// if we have a global set to not show if the end-user has already rated once, and the developer has not opted out of displaying on minor updates
let ratedAnyVersion = userDefaultsObject?.boolForKey(keyForArmchairKeyType(ArmchairKey.RatedAnyVersion))
if let ratedAlready = ratedAnyVersion {
if (!shouldPromptIfRated && ratedAlready) {
return false
}
}
return true
}
fileprivate func userHasDeclinedToRate() -> Bool {
if let declined = userDefaultsObject?.boolForKey(keyForArmchairKeyType(ArmchairKey.DeclinedToRate)) {
return declined
} else {
return false
}
}
fileprivate func userHasRatedCurrentVersion() -> Bool {
if let ratedCurrentVersion = userDefaultsObject?.boolForKey(keyForArmchairKeyType(ArmchairKey.RatedCurrentVersion)) {
return ratedCurrentVersion
} else {
return false
}
}
fileprivate func showsRemindButton() -> Bool {
return (daysBeforeReminding > 0 && remindButtonTitle != nil)
}
public var shouldTryStoreKitReviewPrompt : Bool {
#if os(iOS)
if #available(iOS 10.3, *), useStoreKitReviewPrompt {
return true
}
#endif
return false
}
fileprivate func requestStoreKitReviewPrompt() -> Bool {
if #available(iOS 10.3, OSX 10.14, *), useStoreKitReviewPrompt {
SKStoreReviewController.requestReview()
// Assume this version is rated. There is no API to tell if the user actaully rated.
userDefaultsObject?.setBool(true, forKey: keyForArmchairKeyType(ArmchairKey.RatedCurrentVersion))
userDefaultsObject?.setBool(true, forKey: keyForArmchairKeyType(ArmchairKey.RatedAnyVersion))
userDefaultsObject?.synchronize()
#if os(iOS)
closeModalPanel()
#endif
return true
}
return false
}
fileprivate func showRatingAlert() {
if let customClosure = customAlertClosure {
customClosure({[weak self] in
if let result = self?.requestStoreKitReviewPrompt(), result {
///Showed storekit prompt, all done
} else {
/// Didn't show storekit prompt, present app store manually
self?._rateApp()
}
}, {[weak self] in self?.remindMeLater()}, {[weak self] in self?.dontRate()})
if let closure = self.didDisplayAlertClosure {
closure()
}
} else {
if requestStoreKitReviewPrompt() {
///Showed storekit prompt, all done
return
}
#if os(iOS)
/// Didn't show storekit prompt, present app store manually
let alertView : UIAlertController = UIAlertController(title: reviewTitle, message: reviewMessage, preferredStyle: .alert)
alertView.addAction(UIAlertAction(title: rateButtonTitle, style: .default, handler: {
(alert: UIAlertAction!) in
self._rateApp()
}))
if (showsRemindButton()) {
alertView.addAction(UIAlertAction(title: remindButtonTitle!, style: .default, handler: {
(alert: UIAlertAction!) in
self.remindMeLater()
}))
}
alertView.addAction(UIAlertAction(title: cancelButtonTitle, style: .cancel, handler: {
(alert: UIAlertAction!) in
self.dontRate()
}))
ratingAlert = alertView
// get the top most controller (= the StoreKit Controller) and dismiss it
if let presentingController = UIApplication.shared.keyWindow?.rootViewController {
if let topController = Manager.topMostViewController(presentingController) {
topController.present(alertView, animated: usesAnimation) { [weak self] in
if let closure = self?.didDisplayAlertClosure {
closure()
}
print("presentViewController() completed")
}
}
// note that tint color has to be set after the controller is presented in order to take effect (last checked in iOS 9.3)
alertView.view.tintColor = tintColor
}
#elseif os(OSX)
let alert: NSAlert = NSAlert()
alert.messageText = reviewTitle
alert.informativeText = reviewMessage
alert.addButton(withTitle: rateButtonTitle)
if showsRemindButton() {
alert.addButton(withTitle: remindButtonTitle!)
}
alert.addButton(withTitle: cancelButtonTitle)
ratingAlert = alert
if let window = NSApplication.shared.keyWindow {
alert.beginSheetModal(for: window) {
(response: NSApplication.ModalResponse) in
self.handleNSAlertResponse(response)
}
} else {
let response = alert.runModal()
handleNSAlertResponse(response)
}
if let closure = self.didDisplayAlertClosure {
closure()
}
#else
#endif
}
}
// MARK: -
// MARK: PRIVATE Alert View / StoreKit Delegate Methods
#if os(iOS)
//Delegate call from the StoreKit view.
open func productViewControllerDidFinish(_ viewController: SKStoreProductViewController!) {
closeModalPanel()
}
//Close the in-app rating (StoreKit) view and restore the previous status bar style.
fileprivate func closeModalPanel() {
let usedAnimation = usesAnimation
if modalPanelOpen {
UIApplication.shared.setStatusBarStyle(currentStatusBarStyle, animated:usesAnimation)
modalPanelOpen = false
// get the top most controller (= the StoreKit Controller) and dismiss it
if let presentingController = UIApplication.shared.keyWindow?.rootViewController {
if let topController = Manager.topMostViewController(presentingController) {
topController.dismiss(animated: usesAnimation) {}
currentStatusBarStyle = UIStatusBarStyle.default
}
}
}
if let closure = self.didDismissModalViewClosure {
closure(usedAnimation)
}
}
#elseif os(OSX)
private func handleNSAlertResponse(_ response: NSApplication.ModalResponse) {
switch (response) {
case .alertFirstButtonReturn:
// they want to rate it
_rateApp()
case .alertSecondButtonReturn:
// remind them later or cancel
if showsRemindButton() {
remindMeLater()
} else {
dontRate()
}
case .alertThirdButtonReturn:
// they don't want to rate it
dontRate()
default:
return
}
}
#else
#endif
private func dontRate() {
userDefaultsObject?.setBool(true, forKey: keyForArmchairKeyType(ArmchairKey.DeclinedToRate))
userDefaultsObject?.synchronize()
if let closure = didDeclineToRateClosure {
closure()
}
}
private func remindMeLater() {
userDefaultsObject?.setDouble(Date().timeIntervalSince1970, forKey: keyForArmchairKeyType(ArmchairKey.ReminderRequestDate))
userDefaultsObject?.synchronize()
if let closure = didOptToRemindLaterClosure {
closure()
}
}
private func _rateApp() {
rateApp()
if let closure = didOptToRateClosure {
closure()
}
}
fileprivate func rateApp() {
userDefaultsObject?.setBool(true, forKey: keyForArmchairKeyType(ArmchairKey.RatedCurrentVersion))
userDefaultsObject?.setBool(true, forKey: keyForArmchairKeyType(ArmchairKey.RatedAnyVersion))
userDefaultsObject?.synchronize()
#if os(iOS)
// Use the in-app StoreKit view if set, available (iOS 7+) and imported. This works in the simulator.
if opensInStoreKit {
let storeViewController = SKStoreProductViewController()
var productParameters: [String:AnyObject]! = [SKStoreProductParameterITunesItemIdentifier : appID as AnyObject]
if (operatingSystemVersion >= 8) {
productParameters[SKStoreProductParameterAffiliateToken] = affiliateCode as AnyObject?
productParameters[SKStoreProductParameterCampaignToken] = affiliateCampaignCode as AnyObject?
}
storeViewController.loadProduct(withParameters: productParameters, completionBlock: nil)
storeViewController.delegate = self
if let closure = willPresentModalViewClosure {
closure(usesAnimation)
}
if let rootController = Manager.getRootViewController() {
rootController.present(storeViewController, animated: usesAnimation) {
self.modalPanelOpen = true
//Temporarily use a status bar to match the StoreKit view.
self.currentStatusBarStyle = UIApplication.shared.statusBarStyle
UIApplication.shared.setStatusBarStyle(UIStatusBarStyle.default, animated: self.usesAnimation)
}
}
//Use the standard openUrl method
} else {
if let url = URL(string: reviewURLString()) {
UIApplication.shared.openURL(url)
}
}
#if targetEnvironment(simulator)
debugLog("iTunes App Store is not supported on the iOS simulator.")
debugLog(" - We would have went to \(reviewURLString()).")
debugLog(" - Try running on a test-device")
let fakeURL = reviewURLString().replacingOccurrences(of: "itms-apps", with:"http")
debugLog(" - Or try copy/pasting \(fakeURL) into a browser on your computer.")
#endif
#elseif os(OSX)
if let url = URL(string: reviewURLString()) {
let opened = NSWorkspace.shared.open(url)
if !opened {
debugLog("Failed to open \(url)")
}
}
#else
#endif
}
fileprivate func reviewURLString() -> String {
#if os(iOS)
let template = operatingSystemVersion >= 11 ? reviewURLTemplateiOS11 : reviewURLTemplate
#elseif os(OSX)
let template = reviewURLTemplate
#else
#endif
var reviewURL = template.replacingOccurrences(of: "APP_ID", with: "\(appID)")
reviewURL = reviewURL.replacingOccurrences(of: "AFFILIATE_CODE", with: "\(affiliateCode)")
reviewURL = reviewURL.replacingOccurrences(of: "AFFILIATE_CAMPAIGN_CODE", with: "\(affiliateCampaignCode)")
return reviewURL
}
// Mark: -
// Mark: PRIVATE Key Helpers
private func trackingInfo() -> ArmchairTrackingInfo {
var trackingInfo: Dictionary<ArmchairKey, AnyObject> = [:]
for keyType in ArmchairKey.allValues {
let obj: AnyObject? = userDefaultsObject?.objectForKey(keyForArmchairKeyType(keyType))
if let val = obj as? NSObject {
trackingInfo[keyType] = val
} else {
trackingInfo[keyType] = NSNull()
}
}
return ArmchairTrackingInfo(info: trackingInfo)
}
fileprivate func keyForArmchairKeyType(_ keyType: ArmchairKey) -> String {
switch (keyType) {
case .FirstUseDate:
return keyPrefix + armchairKeyFirstUseDate
case .UseCount:
return keyPrefix + armchairKeyUseCount
case .SignificantEventCount:
return keyPrefix + armchairKeySignificantEventCount
case .CurrentVersion:
return keyPrefix + armchairKeyCurrentVersion
case .RatedCurrentVersion:
return keyPrefix + armchairKeyRatedCurrentVersion
case .DeclinedToRate:
return keyPrefix + armchairKeyDeclinedToRate
case .ReminderRequestDate:
return keyPrefix + armchairKeyReminderRequestDate
case .PreviousVersion:
return keyPrefix + armchairKeyPreviousVersion
case .PreviousVersionRated:
return keyPrefix + armchairKeyPreviousVersionRated
case .PreviousVersionDeclinedToRate:
return keyPrefix + armchairKeyPreviousVersionDeclinedToRate
case .RatedAnyVersion:
return keyPrefix + armchairKeyRatedAnyVersion
case .AppiraterMigrationCompleted:
return keyPrefix + armchairKeyAppiraterMigrationCompleted
case .UAAppReviewManagerMigrationCompleted:
return keyPrefix + armchairKeyUAAppReviewManagerMigrationCompleted
}
}
fileprivate func setKey(_ key: NSString, armchairKeyType: ArmchairKey) {
switch armchairKeyType {
case .FirstUseDate:
armchairKeyFirstUseDate = key as String
case .UseCount:
armchairKeyUseCount = key as String
case .SignificantEventCount:
armchairKeySignificantEventCount = key as String
case .CurrentVersion:
armchairKeyCurrentVersion = key as String
case .RatedCurrentVersion:
armchairKeyRatedCurrentVersion = key as String
case .DeclinedToRate:
armchairKeyDeclinedToRate = key as String
case .ReminderRequestDate:
armchairKeyReminderRequestDate = key as String
case .PreviousVersion:
armchairKeyPreviousVersion = key as String
case .PreviousVersionRated:
armchairKeyPreviousVersionRated = key as String
case .PreviousVersionDeclinedToRate:
armchairKeyPreviousVersionDeclinedToRate = key as String
case .RatedAnyVersion:
armchairKeyRatedAnyVersion = key as String
case .AppiraterMigrationCompleted:
armchairKeyAppiraterMigrationCompleted = key as String
case .UAAppReviewManagerMigrationCompleted:
armchairKeyUAAppReviewManagerMigrationCompleted = key as String
}
}
private func armchairKeyForAppiraterKey(_ appiraterKey: String) -> String {
switch appiraterKey {
case AppiraterKey.FirstUseDate:
return keyForArmchairKeyType(ArmchairKey.FirstUseDate)
case AppiraterKey.UseCount:
return keyForArmchairKeyType(ArmchairKey.UseCount)
case AppiraterKey.SignificantEventCount:
return keyForArmchairKeyType(ArmchairKey.SignificantEventCount)
case AppiraterKey.CurrentVersion:
return keyForArmchairKeyType(ArmchairKey.CurrentVersion)
case AppiraterKey.RatedCurrentVersion:
return keyForArmchairKeyType(ArmchairKey.RatedCurrentVersion)
case AppiraterKey.DeclinedToRate:
return keyForArmchairKeyType(ArmchairKey.DeclinedToRate)
case AppiraterKey.ReminderRequestDate:
return keyForArmchairKeyType(ArmchairKey.ReminderRequestDate)
case AppiraterKey.RatedAnyVersion:
return keyForArmchairKeyType(ArmchairKey.RatedAnyVersion)
default:
return ""
}
}
private func migrateAppiraterKeysIfNecessary() {
let appiraterAlreadyCompletedKey: NSString = keyForArmchairKeyType(.AppiraterMigrationCompleted) as NSString
let appiraterMigrationAlreadyCompleted = userDefaultsObject?.boolForKey(appiraterAlreadyCompletedKey as String)
if let completed = appiraterMigrationAlreadyCompleted {
if completed {
return
}
}
let oldKeys: [String] = [AppiraterKey.FirstUseDate,
AppiraterKey.UseCount,
AppiraterKey.SignificantEventCount,
AppiraterKey.CurrentVersion,
AppiraterKey.RatedCurrentVersion,
AppiraterKey.RatedAnyVersion,
AppiraterKey.DeclinedToRate,
AppiraterKey.ReminderRequestDate]
for oldKey in oldKeys {
let oldValue: NSObject? = userDefaultsObject?.objectForKey(oldKey) as? NSObject
if let val = oldValue {
let newKey = armchairKeyForAppiraterKey(oldKey)
userDefaultsObject?.setObject(val, forKey: newKey)
userDefaultsObject?.removeObjectForKey(oldKey)
}
}
userDefaultsObject?.setObject(NSNumber(value: true), forKey: appiraterAlreadyCompletedKey as String)
userDefaultsObject?.synchronize()
}
// This only supports the default UAAppReviewManager keys. If you customized them, you will have to manually migrate your values over.
private func migrateUAAppReviewManagerKeysIfNecessary() {
let appReviewManagerAlreadyCompletedKey: NSString = keyForArmchairKeyType(.UAAppReviewManagerMigrationCompleted) as NSString
let appReviewManagerMigrationAlreadyCompleted = userDefaultsObject?.boolForKey(appReviewManagerAlreadyCompletedKey as String)
if let completed = appReviewManagerMigrationAlreadyCompleted {
if completed {
return
}
}
// By default, UAAppReviewManager keys are in the format <appID>_UAAppReviewManagerKey<keyType>
let oldKeys: [String:ArmchairKey] = ["\(appID)_UAAppReviewManagerKeyFirstUseDate" : ArmchairKey.FirstUseDate,
"\(appID)_UAAppReviewManagerKeyUseCount" : ArmchairKey.UseCount,
"\(appID)_UAAppReviewManagerKeySignificantEventCount" : ArmchairKey.SignificantEventCount,
"\(appID)_UAAppReviewManagerKeyCurrentVersion" : ArmchairKey.CurrentVersion,
"\(appID)_UAAppReviewManagerKeyRatedCurrentVersion" : ArmchairKey.RatedCurrentVersion,
"\(appID)_UAAppReviewManagerKeyDeclinedToRate" : ArmchairKey.DeclinedToRate,
"\(appID)_UAAppReviewManagerKeyReminderRequestDate" : ArmchairKey.ReminderRequestDate,
"\(appID)_UAAppReviewManagerKeyPreviousVersion" : ArmchairKey.PreviousVersion,
"\(appID)_UAAppReviewManagerKeyPreviousVersionRated" : ArmchairKey.PreviousVersionRated,
"\(appID)_UAAppReviewManagerKeyPreviousVersionDeclinedToRate" : ArmchairKey.PreviousVersionDeclinedToRate,
"\(appID)_UAAppReviewManagerKeyRatedAnyVersion" : ArmchairKey.RatedAnyVersion]
for (oldKey, newKeyType) in oldKeys {
let oldValue: NSObject? = userDefaultsObject?.objectForKey(oldKey) as? NSObject
if let val = oldValue {
userDefaultsObject?.setObject(val, forKey: keyForArmchairKeyType(newKeyType))
userDefaultsObject?.removeObjectForKey(oldKey)
}
}
userDefaultsObject?.setObject(NSNumber(value: true), forKey: appReviewManagerAlreadyCompletedKey as String)
userDefaultsObject?.synchronize()
}
private func migrateKeysIfNecessary() {
migrateAppiraterKeysIfNecessary()
migrateUAAppReviewManagerKeysIfNecessary()
}
// MARK: -
// MARK: Internet Connectivity
private func connectedToNetwork() -> Bool {
var zeroAddress = sockaddr_in()
zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress))
zeroAddress.sin_family = sa_family_t(AF_INET)
guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
SCNetworkReachabilityCreateWithAddress(nil, $0)
}
}) else {
return false
}
var flags : SCNetworkReachabilityFlags = []
if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) {
return false
}
let isReachable = flags.contains(.reachable)
let needsConnection = flags.contains(.connectionRequired)
return (isReachable && !needsConnection)
}
// MARK: -
// MARK: PRIVATE Misc Helpers
private func bundle() -> Bundle? {
var bundle: Bundle? = nil
if useMainAppBundleForLocalizations {
bundle = Bundle.main
} else {
let armchairBundleURL: URL? = Bundle.main.url(forResource: "Armchair", withExtension: "bundle")
if let url = armchairBundleURL {
bundle = Bundle(url: url)
} else {
bundle = Bundle(for: type(of: self))
}
}
return bundle
}
#if os(iOS)
private static func topMostViewController(_ controller: UIViewController?) -> UIViewController? {
var isPresenting: Bool = false
var topController: UIViewController? = controller
repeat {
// this path is called only on iOS 6+, so -presentedViewController is fine here.
if let controller = topController {
if let presented = controller.presentedViewController {
isPresenting = true
topController = presented
} else {
isPresenting = false
}
}
} while isPresenting
return topController
}
private static func getRootViewController() -> UIViewController? {
if var window = UIApplication.shared.keyWindow {
if window.windowLevel != UIWindow.Level.normal {
let windows: NSArray = UIApplication.shared.windows as NSArray
for candidateWindow in windows {
if let candidateWindow = candidateWindow as? UIWindow {
if candidateWindow.windowLevel == UIWindow.Level.normal {
window = candidateWindow
break
}
}
}
}
return iterateSubViewsForViewController(window)
}
return nil
}
private static func iterateSubViewsForViewController(_ parentView: UIView) -> UIViewController? {
for subView in parentView.subviews {
if let responder = subView.next {
if responder.isKind(of: UIViewController.self) {
return topMostViewController(responder as? UIViewController)
}
}
if let found = iterateSubViewsForViewController(subView) {
return found
}
}
return nil
}
#endif
private func hideRatingAlert() {
if let alert = ratingAlert {
debugLog("Hiding Alert")
#if os(iOS)
let isAlertVisible = alert.isViewLoaded && alert.view.window != nil
if isAlertVisible {
alert.dismiss(animated: false, completion: {
self.dontRate()
})
}
#elseif os(OSX)
if let window = NSApplication.shared.keyWindow {
if let parent = window.sheetParent {
parent.endSheet(window)
}
}
#else
#endif
ratingAlert = nil
}
}
fileprivate func defaultAffiliateCode() -> String {
return "11l7j9"
}
fileprivate func defaultAffiliateCampaignCode() -> String {
return "Armchair"
}
// MARK: -
// MARK: Notification Handlers
@objc public func appWillResignActive(_ notification: Notification) {
debugLog("appWillResignActive:")
hideRatingAlert()
}
@objc public func applicationDidFinishLaunching(_ notification: Notification) {
DispatchQueue.global(qos: .background).async {
self.debugLog("applicationDidFinishLaunching:")
self.migrateKeysIfNecessary()
self.incrementUseCount()
}
}
@objc public func applicationWillEnterForeground(_ notification: Notification) {
DispatchQueue.global(qos: .background).async {
self.debugLog("applicationWillEnterForeground:")
self.migrateKeysIfNecessary()
self.incrementUseCount()
}
}
// MARK: -
// MARK: Singleton
public class var defaultManager: Manager {
assert(Armchair.appID != "", "Armchair.appID(appID: String) has to be the first Armchair call made.")
struct Singleton {
static let instance: Manager = Manager(appID: Armchair.appID)
}
return Singleton.instance
}
init(appID: String) {
super.init()
setupNotifications()
}
// MARK: Singleton Instance Setup
fileprivate func setupNotifications() {
#if os(iOS)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.appWillResignActive(_:)), name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.applicationDidFinishLaunching(_:)), name: UIApplication.didFinishLaunchingNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.applicationWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
#elseif os(OSX)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.appWillResignActive(_:)), name: NSApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.applicationDidFinishLaunching(_:)), name: NSApplication.didFinishLaunchingNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.applicationWillEnterForeground(_:)), name: NSApplication.willBecomeActiveNotification, object: nil)
#else
#endif
}
// MARK: -
// MARK: Printable
override open var debugDescription: String {
get {
return "Armchair: appID=\(Armchair.appID)"
}
}
// MARK: -
// MARK: Debug
let lockQueue = DispatchQueue(label: "com.armchair.lockqueue")
public var logger: ArmchairLogger = { manager, log, file, function, line in
if manager.debugEnabled {
manager.lockQueue.sync(execute: {
print("[Armchair] \(log)")
})
}
}
fileprivate func debugLog(_ log: String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
logger(self, log, file, function, line)
}
}