Files
Armchair/Source/Armchair.swift
whitepixelstudios bf8c97e92b Update Readme, Fix custom alerts, update review URL
Update version in podspec to 0.3.0 (Swift 3)
Update readme to match podfile
Make custom alert closure parameters escaping
Review URL: auto-open to write review page
2017-02-25 16:00:33 +10:00

1824 lines
78 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
}
/*
* 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
}
/*
* '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 {
open 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, UIAlertViewDelegate, 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: UIAlertView? = 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"
#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"
#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)
}
fileprivate func showRatingAlert() {
if let customClosure = customAlertClosure {
customClosure({[weak self] in self?._rateApp()}, {[weak self] in self?.remindMeLater()}, {[weak self] in self?.dontRate()})
if let closure = self.didDisplayAlertClosure {
closure()
}
} else {
#if os(iOS)
if (operatingSystemVersion >= 8 && usesAlertController) || operatingSystemVersion >= 9 {
/* iOS 8 uses new UIAlertController API*/
let alertView : UIAlertController = UIAlertController(title: reviewTitle, message: reviewMessage, preferredStyle: UIAlertControllerStyle.alert)
alertView.addAction(UIAlertAction(title: cancelButtonTitle, style:UIAlertActionStyle.cancel, handler: {
(alert: UIAlertAction!) in
self.dontRate()
}))
if (showsRemindButton()) {
alertView.addAction(UIAlertAction(title: remindButtonTitle!, style:UIAlertActionStyle.default, handler: {
(alert: UIAlertAction!) in
self.remindMeLater()
}))
}
alertView.addAction(UIAlertAction(title: rateButtonTitle, style:UIAlertActionStyle.default, handler: {
(alert: UIAlertAction!) in
self._rateApp()
}))
// get the top most controller (= the StoreKit Controller) and dismiss it
if let presentingController = UIApplication.shared.keyWindow?.rootViewController {
if let topController = topMostViewController(presentingController) {
topController.present(alertView, animated: usesAnimation) {
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
}
} else {
/* Otherwise we use UIAlertView still */
var alertView: UIAlertView
if (showsRemindButton()) {
alertView = UIAlertView(title: reviewTitle, message: reviewMessage, delegate: self, cancelButtonTitle: cancelButtonTitle, otherButtonTitles: remindButtonTitle!, rateButtonTitle)
} else {
alertView = UIAlertView(title: reviewTitle, message: reviewMessage, delegate: self, cancelButtonTitle: cancelButtonTitle, otherButtonTitles: rateButtonTitle)
}
// If we have a remind button, show it first. Otherwise show the rate button
// If we have a remind button, show the rate button next. Otherwise stop adding buttons.
alertView.cancelButtonIndex = -1
ratingAlert = alertView
alertView.show()
if let closure = didDisplayAlertClosure {
closure()
}
}
#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: NSModalResponse) in
self.handleNSAlert(returnCode: response)
}
} else {
let returnCode = alert.runModal()
handleNSAlert(returnCode:returnCode)
}
if let closure = self.didDisplayAlertClosure {
closure()
}
#else
#endif
}
}
// MARK: -
// MARK: PRIVATE Alert View / StoreKit Delegate Methods
#if os(iOS)
open func alertView(_ alertView: UIAlertView, didDismissWithButtonIndex buttonIndex: Int) {
// cancelButtonIndex is set to -1 to show the cancel button up top, but a tap on it ends up here with index 0
if (alertView.cancelButtonIndex == buttonIndex || 0 == buttonIndex) {
// they don't want to rate it
dontRate()
} else if (showsRemindButton() && 1 == buttonIndex) {
// remind them later
remindMeLater()
} else {
// they want to rate it
_rateApp()
}
}
//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() {
if modalPanelOpen {
UIApplication.shared.setStatusBarStyle(currentStatusBarStyle, animated:usesAnimation)
let usedAnimation = usesAnimation
modalPanelOpen = false
// get the top most controller (= the StoreKit Controller) and dismiss it
if let presentingController = UIApplication.shared.keyWindow?.rootViewController {
if let topController = topMostViewController(presentingController) {
topController.dismiss(animated: usesAnimation) {
if let closure = self.didDismissModalViewClosure {
closure(usedAnimation)
}
}
currentStatusBarStyle = UIStatusBarStyle.default
}
}
}
}
#elseif os(OSX)
private func handleNSAlert(returnCode: NSInteger) {
switch (returnCode) {
case NSAlertFirstButtonReturn:
// they want to rate it
_rateApp()
case NSAlertSecondButtonReturn:
// remind them later or cancel
if showsRemindButton() {
remindMeLater()
} else {
dontRate()
}
case NSAlertThirdButtonReturn:
// 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 = 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)
}
}
// Check for iOS simulator
#if (arch(i386) || arch(x86_64)) && os(iOS)
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 {
let template = reviewURLTemplate
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 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 func getRootViewController() -> UIViewController? {
if var window = UIApplication.shared.keyWindow {
if window.windowLevel != UIWindowLevelNormal {
let windows: NSArray = UIApplication.shared.windows as NSArray
for candidateWindow in windows {
if let candidateWindow = candidateWindow as? UIWindow {
if candidateWindow.windowLevel == UIWindowLevelNormal {
window = candidateWindow
break
}
}
}
}
for subView in window.subviews {
if let responder = subView.next {
if responder.isKind(of: UIViewController.self) {
return topMostViewController(responder as? UIViewController)
}
}
}
}
return nil
}
#endif
private func hideRatingAlert() {
if let alert = ratingAlert {
debugLog("Hiding Alert")
#if os(iOS)
if alert.isVisible {
alert.dismiss(withClickedButtonIndex: alert.cancelButtonIndex, animated: false)
}
#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
public func appWillResignActive(_ notification: Notification) {
debugLog("appWillResignActive:")
hideRatingAlert()
}
public func applicationDidFinishLaunching(_ notification: Notification) {
DispatchQueue.global(qos: .background).async {
self.debugLog("applicationDidFinishLaunching:")
self.migrateKeysIfNecessary()
self.incrementUseCount()
}
}
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: NSNotification.Name.UIApplicationWillResignActive, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(UIApplicationDelegate.applicationDidFinishLaunching(_:)), name: NSNotification.Name.UIApplicationDidFinishLaunching, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(UIApplicationDelegate.applicationWillEnterForeground(_:)), name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil)
#elseif os(OSX)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.appWillResignActive(_:)), name: NSNotification.Name.NSApplicationWillResignActive, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(NSApplicationDelegate.applicationDidFinishLaunching(_:)), name: NSNotification.Name.NSApplicationDidFinishLaunching, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(Manager.applicationWillEnterForeground(_:)), name: NSNotification.Name.NSApplicationWillBecomeActive, 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)
}
}