mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			562 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			562 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Swift
		
	
//
 | 
						|
//  Copyright (c) 2018 Open Whisper Systems. All rights reserved.
 | 
						|
//
 | 
						|
 | 
						|
import Foundation
 | 
						|
import Contacts
 | 
						|
import ContactsUI
 | 
						|
import SignalServiceKit
 | 
						|
 | 
						|
enum Result<T, ErrorType> {
 | 
						|
    case success(T)
 | 
						|
    case error(ErrorType)
 | 
						|
}
 | 
						|
 | 
						|
protocol ContactStoreAdaptee {
 | 
						|
    var authorizationStatus: ContactStoreAuthorizationStatus { get }
 | 
						|
    var supportsContactEditing: Bool { get }
 | 
						|
    func requestAccess(completionHandler: @escaping (Bool, Error?) -> Void)
 | 
						|
    func fetchContacts() -> Result<[Contact], Error>
 | 
						|
    func startObservingChanges(changeHandler: @escaping () -> Void)
 | 
						|
}
 | 
						|
 | 
						|
@available(iOS 9.0, *)
 | 
						|
class ContactsFrameworkContactStoreAdaptee: ContactStoreAdaptee {
 | 
						|
    let TAG = "[ContactsFrameworkContactStoreAdaptee]"
 | 
						|
    private let contactStore = CNContactStore()
 | 
						|
    private var changeHandler: (() -> Void)?
 | 
						|
    private var initializedObserver = false
 | 
						|
    private var lastSortOrder: CNContactSortOrder?
 | 
						|
 | 
						|
    let supportsContactEditing = true
 | 
						|
 | 
						|
    private let allowedContactKeys: [CNKeyDescriptor] = [
 | 
						|
        CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
 | 
						|
        CNContactThumbnailImageDataKey as CNKeyDescriptor, // TODO full image instead of thumbnail?
 | 
						|
        CNContactPhoneNumbersKey as CNKeyDescriptor,
 | 
						|
        CNContactEmailAddressesKey as CNKeyDescriptor,
 | 
						|
        CNContactViewController.descriptorForRequiredKeys()
 | 
						|
    ]
 | 
						|
 | 
						|
    var authorizationStatus: ContactStoreAuthorizationStatus {
 | 
						|
        switch CNContactStore.authorizationStatus(for: CNEntityType.contacts) {
 | 
						|
        case .notDetermined:
 | 
						|
            return .notDetermined
 | 
						|
        case .restricted:
 | 
						|
            return .restricted
 | 
						|
        case .denied:
 | 
						|
            return .denied
 | 
						|
        case .authorized:
 | 
						|
             return .authorized
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    func startObservingChanges(changeHandler: @escaping () -> Void) {
 | 
						|
        // should only call once
 | 
						|
        assert(self.changeHandler == nil)
 | 
						|
        self.changeHandler = changeHandler
 | 
						|
        self.lastSortOrder = CNContactsUserDefaults.shared().sortOrder
 | 
						|
        NotificationCenter.default.addObserver(self, selector: #selector(runChangeHandler), name: .CNContactStoreDidChange, object: nil)
 | 
						|
        NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil)
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    func didBecomeActive() {
 | 
						|
        AppReadiness.runNowOrWhenAppIsReady {
 | 
						|
            let currentSortOrder = CNContactsUserDefaults.shared().sortOrder
 | 
						|
 | 
						|
            guard currentSortOrder != self.lastSortOrder else {
 | 
						|
                // sort order unchanged
 | 
						|
                return
 | 
						|
            }
 | 
						|
 | 
						|
            Logger.info("\(self.TAG) sort order changed: \(String(describing: self.lastSortOrder)) -> \(String(describing: currentSortOrder))")
 | 
						|
            self.lastSortOrder = currentSortOrder
 | 
						|
            self.runChangeHandler()
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    func runChangeHandler() {
 | 
						|
        guard let changeHandler = self.changeHandler else {
 | 
						|
            owsFail("\(TAG) trying to run change handler before it was registered")
 | 
						|
            return
 | 
						|
        }
 | 
						|
        changeHandler()
 | 
						|
    }
 | 
						|
 | 
						|
    func requestAccess(completionHandler: @escaping (Bool, Error?) -> Void) {
 | 
						|
        self.contactStore.requestAccess(for: .contacts, completionHandler: completionHandler)
 | 
						|
    }
 | 
						|
 | 
						|
    func fetchContacts() -> Result<[Contact], Error> {
 | 
						|
        var systemContacts = [CNContact]()
 | 
						|
        do {
 | 
						|
            let contactFetchRequest = CNContactFetchRequest(keysToFetch: self.allowedContactKeys)
 | 
						|
            contactFetchRequest.sortOrder = .userDefault
 | 
						|
            try self.contactStore.enumerateContacts(with: contactFetchRequest) { (contact, _) -> Void in
 | 
						|
                systemContacts.append(contact)
 | 
						|
            }
 | 
						|
        } catch let error as NSError {
 | 
						|
            owsFail("\(self.TAG) Failed to fetch contacts with error:\(error)")
 | 
						|
            return .error(error)
 | 
						|
        }
 | 
						|
 | 
						|
        let contacts = systemContacts.map { Contact(systemContact: $0) }
 | 
						|
        return .success(contacts)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
let kAddressBookContactStoreDidChangeNotificationName = NSNotification.Name("AddressBookContactStoreAdapteeDidChange")
 | 
						|
/**
 | 
						|
 * System contact fetching compatible with iOS8
 | 
						|
 */
 | 
						|
class AddressBookContactStoreAdaptee: ContactStoreAdaptee {
 | 
						|
 | 
						|
    let TAG = "[AddressBookContactStoreAdaptee]"
 | 
						|
 | 
						|
    private var addressBook: ABAddressBook = ABAddressBookCreateWithOptions(nil, nil).takeRetainedValue()
 | 
						|
    private var changeHandler: (() -> Void)?
 | 
						|
    let supportsContactEditing = false
 | 
						|
 | 
						|
    var authorizationStatus: ContactStoreAuthorizationStatus {
 | 
						|
        switch ABAddressBookGetAuthorizationStatus() {
 | 
						|
        case .notDetermined:
 | 
						|
            return .notDetermined
 | 
						|
        case .restricted:
 | 
						|
            return .restricted
 | 
						|
        case .denied:
 | 
						|
            return .denied
 | 
						|
        case .authorized:
 | 
						|
            return .authorized
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    func runChangeHandler() {
 | 
						|
        guard let changeHandler = self.changeHandler else {
 | 
						|
            owsFail("\(TAG) trying to run change handler before it was registered")
 | 
						|
            return
 | 
						|
        }
 | 
						|
        changeHandler()
 | 
						|
    }
 | 
						|
 | 
						|
    func startObservingChanges(changeHandler: @escaping () -> Void) {
 | 
						|
        // should only call once
 | 
						|
        assert(self.changeHandler == nil)
 | 
						|
        self.changeHandler = changeHandler
 | 
						|
 | 
						|
        NotificationCenter.default.addObserver(self, selector: #selector(runChangeHandler), name: kAddressBookContactStoreDidChangeNotificationName, object: nil)
 | 
						|
 | 
						|
        let callback: ABExternalChangeCallback = { (_, _, _) in
 | 
						|
            // Ideally we'd just call the changeHandler here, but because this is a C style callback in swift, 
 | 
						|
            // we can't capture any state in the closure, so we use a notification as a trampoline
 | 
						|
            NotificationCenter.default.postNotificationNameAsync(kAddressBookContactStoreDidChangeNotificationName, object: nil)
 | 
						|
        }
 | 
						|
 | 
						|
        ABAddressBookRegisterExternalChangeCallback(addressBook, callback, nil)
 | 
						|
    }
 | 
						|
 | 
						|
    func requestAccess(completionHandler: @escaping (Bool, Error?) -> Void) {
 | 
						|
        ABAddressBookRequestAccessWithCompletion(addressBook, completionHandler)
 | 
						|
    }
 | 
						|
 | 
						|
    func fetchContacts() -> Result<[Contact], Error> {
 | 
						|
        // Changes are not reflected unless we create a new address book
 | 
						|
        self.addressBook = ABAddressBookCreateWithOptions(nil, nil).takeRetainedValue()
 | 
						|
 | 
						|
        let allPeople = ABAddressBookCopyArrayOfAllPeopleInSourceWithSortOrdering(addressBook, nil, ABPersonGetSortOrdering()).takeRetainedValue() as [ABRecord]
 | 
						|
 | 
						|
        let contacts = allPeople.map { self.buildContact(abRecord: $0) }
 | 
						|
 | 
						|
        return .success(contacts)
 | 
						|
    }
 | 
						|
 | 
						|
    private func buildContact(abRecord: ABRecord) -> Contact {
 | 
						|
 | 
						|
        let addressBookRecord = OWSABRecord(abRecord: abRecord)
 | 
						|
 | 
						|
        var firstName = addressBookRecord.firstName
 | 
						|
        let lastName = addressBookRecord.lastName
 | 
						|
        let phoneNumbers = addressBookRecord.phoneNumbers
 | 
						|
 | 
						|
        if firstName == nil && lastName == nil {
 | 
						|
            if let companyName = addressBookRecord.companyName {
 | 
						|
                firstName = companyName
 | 
						|
            } else {
 | 
						|
                firstName = phoneNumbers.first
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return Contact(firstName: firstName,
 | 
						|
                       lastName: lastName,
 | 
						|
                       userTextPhoneNumbers: phoneNumbers,
 | 
						|
                       imageData: addressBookRecord.imageData,
 | 
						|
                       contactID: addressBookRecord.recordId)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Wrapper around ABRecord for easy property extraction.
 | 
						|
 * Some code lifted from: 
 | 
						|
 * https://github.com/SocialbitGmbH/SwiftAddressBook/blob/c1993fa/Pod/Classes/SwiftAddressBookPerson.swift
 | 
						|
 */
 | 
						|
struct OWSABRecord {
 | 
						|
 | 
						|
    public struct MultivalueEntry<T> {
 | 
						|
        public var value: T
 | 
						|
        public var label: String?
 | 
						|
        public let id: Int
 | 
						|
 | 
						|
        public init(value: T, label: String?, id: Int) {
 | 
						|
            self.value = value
 | 
						|
            self.label = label
 | 
						|
            self.id = id
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    let abRecord: ABRecord
 | 
						|
 | 
						|
    init(abRecord: ABRecord) {
 | 
						|
        self.abRecord = abRecord
 | 
						|
    }
 | 
						|
 | 
						|
    var firstName: String? {
 | 
						|
        return self.extractProperty(kABPersonFirstNameProperty)
 | 
						|
    }
 | 
						|
 | 
						|
    var lastName: String? {
 | 
						|
        return self.extractProperty(kABPersonLastNameProperty)
 | 
						|
    }
 | 
						|
 | 
						|
    var companyName: String? {
 | 
						|
        return self.extractProperty(kABPersonOrganizationProperty)
 | 
						|
    }
 | 
						|
 | 
						|
    var recordId: ABRecordID {
 | 
						|
        return ABRecordGetRecordID(abRecord)
 | 
						|
    }
 | 
						|
 | 
						|
    // We don't yet support labels for our iOS8 users.
 | 
						|
    var phoneNumbers: [String] {
 | 
						|
        if let result: [MultivalueEntry<String>] = extractMultivalueProperty(kABPersonPhoneProperty) {
 | 
						|
            return result.map { $0.value }
 | 
						|
        } else {
 | 
						|
            return []
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    var imageData: Data? {
 | 
						|
        guard ABPersonHasImageData(abRecord) else {
 | 
						|
            return nil
 | 
						|
        }
 | 
						|
        guard let data = ABPersonCopyImageData(abRecord)?.takeRetainedValue() else {
 | 
						|
            return nil
 | 
						|
        }
 | 
						|
        return data as Data
 | 
						|
    }
 | 
						|
 | 
						|
    private func extractProperty<T>(_ propertyName: ABPropertyID) -> T? {
 | 
						|
        let value: AnyObject? = ABRecordCopyValue(self.abRecord, propertyName)?.takeRetainedValue()
 | 
						|
        return value as? T
 | 
						|
    }
 | 
						|
 | 
						|
    fileprivate func extractMultivalueProperty<T>(_ propertyName: ABPropertyID) -> Array<MultivalueEntry<T>>? {
 | 
						|
        guard let multivalue: ABMultiValue = extractProperty(propertyName) else { return nil }
 | 
						|
        var array = Array<MultivalueEntry<T>>()
 | 
						|
        for i: Int in 0..<(ABMultiValueGetCount(multivalue)) {
 | 
						|
            let value: T? = ABMultiValueCopyValueAtIndex(multivalue, i).takeRetainedValue() as? T
 | 
						|
            if let v: T = value {
 | 
						|
                let id: Int = Int(ABMultiValueGetIdentifierAtIndex(multivalue, i))
 | 
						|
                let optionalLabel = ABMultiValueCopyLabelAtIndex(multivalue, i)?.takeRetainedValue()
 | 
						|
                array.append(MultivalueEntry(value: v,
 | 
						|
                                             label: optionalLabel == nil ? nil : optionalLabel! as String,
 | 
						|
                                             id: id))
 | 
						|
            }
 | 
						|
        }
 | 
						|
        return !array.isEmpty ? array : nil
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
public enum ContactStoreAuthorizationStatus {
 | 
						|
    case notDetermined,
 | 
						|
         restricted,
 | 
						|
         denied,
 | 
						|
         authorized
 | 
						|
}
 | 
						|
 | 
						|
class ContactStoreAdapter: ContactStoreAdaptee {
 | 
						|
 | 
						|
    let adaptee: ContactStoreAdaptee
 | 
						|
 | 
						|
    init() {
 | 
						|
        if #available(iOS 9.0, *) {
 | 
						|
            self.adaptee = ContactsFrameworkContactStoreAdaptee()
 | 
						|
        } else {
 | 
						|
            self.adaptee = AddressBookContactStoreAdaptee()
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    var supportsContactEditing: Bool {
 | 
						|
        return self.adaptee.supportsContactEditing
 | 
						|
    }
 | 
						|
 | 
						|
    var authorizationStatus: ContactStoreAuthorizationStatus {
 | 
						|
        return self.adaptee.authorizationStatus
 | 
						|
    }
 | 
						|
 | 
						|
    func requestAccess(completionHandler: @escaping (Bool, Error?) -> Void) {
 | 
						|
        return self.adaptee.requestAccess(completionHandler: completionHandler)
 | 
						|
    }
 | 
						|
 | 
						|
    func fetchContacts() -> Result<[Contact], Error> {
 | 
						|
        return self.adaptee.fetchContacts()
 | 
						|
    }
 | 
						|
 | 
						|
    func startObservingChanges(changeHandler: @escaping () -> Void) {
 | 
						|
        self.adaptee.startObservingChanges(changeHandler: changeHandler)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
@objc public protocol SystemContactsFetcherDelegate: class {
 | 
						|
    func systemContactsFetcher(_ systemContactsFetcher: SystemContactsFetcher, updatedContacts contacts: [Contact], isUserRequested: Bool)
 | 
						|
}
 | 
						|
 | 
						|
@objc
 | 
						|
public class SystemContactsFetcher: NSObject {
 | 
						|
 | 
						|
    private let TAG = "[SystemContactsFetcher]"
 | 
						|
    var lastContactUpdateHash: Int?
 | 
						|
    var lastDelegateNotificationDate: Date?
 | 
						|
    let contactStoreAdapter: ContactStoreAdapter
 | 
						|
 | 
						|
    @objc
 | 
						|
    public weak var delegate: SystemContactsFetcherDelegate?
 | 
						|
 | 
						|
    public var authorizationStatus: ContactStoreAuthorizationStatus {
 | 
						|
        return contactStoreAdapter.authorizationStatus
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    public var isAuthorized: Bool {
 | 
						|
        guard self.authorizationStatus != .notDetermined else {
 | 
						|
            owsFail("should have called `requestOnce` before checking authorization status.")
 | 
						|
            return false
 | 
						|
        }
 | 
						|
 | 
						|
        return self.authorizationStatus == .authorized
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    public private(set) var systemContactsHaveBeenRequestedAtLeastOnce = false
 | 
						|
    private var hasSetupObservation = false
 | 
						|
 | 
						|
    override init() {
 | 
						|
        self.contactStoreAdapter = ContactStoreAdapter()
 | 
						|
 | 
						|
        super.init()
 | 
						|
 | 
						|
        SwiftSingletons.register(self)
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    public var supportsContactEditing: Bool {
 | 
						|
        return self.contactStoreAdapter.supportsContactEditing
 | 
						|
    }
 | 
						|
 | 
						|
    private func setupObservationIfNecessary() {
 | 
						|
        AssertIsOnMainThread()
 | 
						|
        guard !hasSetupObservation else {
 | 
						|
            return
 | 
						|
        }
 | 
						|
        hasSetupObservation = true
 | 
						|
        self.contactStoreAdapter.startObservingChanges { [weak self] in
 | 
						|
            DispatchQueue.main.async {
 | 
						|
                self?.updateContacts(completion: nil, isUserRequested: false)
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Ensures we've requested access for system contacts. This can be used in multiple places,
 | 
						|
     * where we might need contact access, but will ensure we don't wastefully reload contacts
 | 
						|
     * if we have already fetched contacts.
 | 
						|
     *
 | 
						|
     * @param   completion  completion handler is called on main thread.
 | 
						|
     */
 | 
						|
    @objc
 | 
						|
    public func requestOnce(completion completionParam: ((Error?) -> Void)?) {
 | 
						|
        AssertIsOnMainThread()
 | 
						|
 | 
						|
        // Ensure completion is invoked on main thread.
 | 
						|
        let completion = { error in
 | 
						|
            DispatchMainThreadSafe({
 | 
						|
                completionParam?(error)
 | 
						|
            })
 | 
						|
        }
 | 
						|
 | 
						|
        guard !systemContactsHaveBeenRequestedAtLeastOnce else {
 | 
						|
            completion(nil)
 | 
						|
            return
 | 
						|
        }
 | 
						|
        setupObservationIfNecessary()
 | 
						|
 | 
						|
        switch authorizationStatus {
 | 
						|
        case .notDetermined:
 | 
						|
            if CurrentAppContext().isInBackground() {
 | 
						|
                Logger.error("\(self.TAG) do not request contacts permission when app is in background")
 | 
						|
                completion(nil)
 | 
						|
                return
 | 
						|
            }
 | 
						|
            self.contactStoreAdapter.requestAccess { (granted, error) in
 | 
						|
                if let error = error {
 | 
						|
                    Logger.error("\(self.TAG) error fetching contacts: \(error)")
 | 
						|
                    completion(error)
 | 
						|
                    return
 | 
						|
                }
 | 
						|
 | 
						|
                guard granted else {
 | 
						|
                    // This case should have been caught be the error guard a few lines up.
 | 
						|
                    owsFail("\(self.TAG) declined contact access.")
 | 
						|
                    completion(nil)
 | 
						|
                    return
 | 
						|
                }
 | 
						|
 | 
						|
                DispatchQueue.main.async {
 | 
						|
                    self.updateContacts(completion: completion)
 | 
						|
                }
 | 
						|
            }
 | 
						|
        case .authorized:
 | 
						|
            self.updateContacts(completion: completion)
 | 
						|
        case .denied, .restricted:
 | 
						|
            Logger.debug("\(TAG) contacts were \(self.authorizationStatus)")
 | 
						|
            completion(nil)
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    public func fetchOnceIfAlreadyAuthorized() {
 | 
						|
        AssertIsOnMainThread()
 | 
						|
        guard authorizationStatus == .authorized else {
 | 
						|
            return
 | 
						|
        }
 | 
						|
        guard !systemContactsHaveBeenRequestedAtLeastOnce else {
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        updateContacts(completion: nil, isUserRequested: false)
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    public func userRequestedRefresh(completion: @escaping (Error?) -> Void) {
 | 
						|
        AssertIsOnMainThread()
 | 
						|
        guard authorizationStatus == .authorized else {
 | 
						|
            owsFail("should have already requested contact access")
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        updateContacts(completion: completion, isUserRequested: true)
 | 
						|
    }
 | 
						|
 | 
						|
    private func updateContacts(completion completionParam: ((Error?) -> Void)?, isUserRequested: Bool = false) {
 | 
						|
        AssertIsOnMainThread()
 | 
						|
 | 
						|
        // Ensure completion is invoked on main thread.
 | 
						|
        let completion = { error in
 | 
						|
            DispatchMainThreadSafe({
 | 
						|
                completionParam?(error)
 | 
						|
            })
 | 
						|
        }
 | 
						|
 | 
						|
        systemContactsHaveBeenRequestedAtLeastOnce = true
 | 
						|
        setupObservationIfNecessary()
 | 
						|
 | 
						|
        DispatchQueue.global().async {
 | 
						|
 | 
						|
            Logger.info("\(self.TAG) fetching contacts")
 | 
						|
 | 
						|
            var fetchedContacts: [Contact]?
 | 
						|
            switch self.contactStoreAdapter.fetchContacts() {
 | 
						|
            case .success(let result):
 | 
						|
                fetchedContacts = result
 | 
						|
            case .error(let error):
 | 
						|
                completion(error)
 | 
						|
                return
 | 
						|
            }
 | 
						|
 | 
						|
            guard let contacts = fetchedContacts else {
 | 
						|
                owsFail("\(self.TAG) contacts was unexpectedly not set.")
 | 
						|
                completion(nil)
 | 
						|
            }
 | 
						|
 | 
						|
            Logger.info("\(self.TAG) fetched \(contacts.count) contacts.")
 | 
						|
            let contactsHash  = HashableArray(contacts).hashValue
 | 
						|
 | 
						|
            DispatchQueue.main.async {
 | 
						|
                var shouldNotifyDelegate = false
 | 
						|
 | 
						|
                if self.lastContactUpdateHash != contactsHash {
 | 
						|
                    Logger.info("\(self.TAG) contact hash changed. new contactsHash: \(contactsHash)")
 | 
						|
                    shouldNotifyDelegate = true
 | 
						|
                } else if isUserRequested {
 | 
						|
                    Logger.info("\(self.TAG) ignoring debounce due to user request")
 | 
						|
                    shouldNotifyDelegate = true
 | 
						|
                } else {
 | 
						|
 | 
						|
                    // If nothing has changed, only notify delegate (to perform contact intersection) every N hours
 | 
						|
                    if let lastDelegateNotificationDate = self.lastDelegateNotificationDate {
 | 
						|
                        let kDebounceInterval = TimeInterval(12 * 60 * 60)
 | 
						|
 | 
						|
                        let expiresAtDate = Date(timeInterval: kDebounceInterval, since: lastDelegateNotificationDate)
 | 
						|
                        if  Date() > expiresAtDate {
 | 
						|
                            Logger.info("\(self.TAG) debounce interval expired at: \(expiresAtDate)")
 | 
						|
                            shouldNotifyDelegate = true
 | 
						|
                        } else {
 | 
						|
                            Logger.info("\(self.TAG) ignoring since debounce interval hasn't expired")
 | 
						|
                        }
 | 
						|
                    } else {
 | 
						|
                        Logger.info("\(self.TAG) first contact fetch. contactsHash: \(contactsHash)")
 | 
						|
                        shouldNotifyDelegate = true
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                guard shouldNotifyDelegate else {
 | 
						|
                    Logger.info("\(self.TAG) no reason to notify delegate.")
 | 
						|
 | 
						|
                    completion(nil)
 | 
						|
 | 
						|
                    return
 | 
						|
                }
 | 
						|
 | 
						|
                self.lastDelegateNotificationDate = Date()
 | 
						|
                self.lastContactUpdateHash = contactsHash
 | 
						|
 | 
						|
                self.delegate?.systemContactsFetcher(self, updatedContacts: contacts, isUserRequested: isUserRequested)
 | 
						|
                completion(nil)
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
struct HashableArray<Element: Hashable>: Hashable {
 | 
						|
    var elements: [Element]
 | 
						|
    init(_ elements: [Element]) {
 | 
						|
        self.elements = elements
 | 
						|
    }
 | 
						|
 | 
						|
    var hashValue: Int {
 | 
						|
        // random generated 32bit number
 | 
						|
        let base = 224712574
 | 
						|
        var position = 0
 | 
						|
        return elements.reduce(base) { (result, element) -> Int in
 | 
						|
            // Make sure change in sort order invalidates hash
 | 
						|
            position += 1
 | 
						|
            return result ^ element.hashValue + position
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    static func == (lhs: HashableArray, rhs: HashableArray) -> Bool {
 | 
						|
        return lhs.hashValue == rhs.hashValue
 | 
						|
    }
 | 
						|
}
 |