session-ios/SignalMessaging/contacts/SystemContactsFetcher.swift

562 lines
19 KiB
Swift
Raw Normal View History

//
// 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>
2017-05-18 02:24:08 +02:00
func startObservingChanges(changeHandler: @escaping () -> Void)
}
@available(iOS 9.0, *)
class ContactsFrameworkContactStoreAdaptee: ContactStoreAdaptee {
let TAG = "[ContactsFrameworkContactStoreAdaptee]"
private let contactStore = CNContactStore()
2017-05-18 02:24:08 +02:00
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
}
}
2017-05-18 02:24:08 +02:00
func startObservingChanges(changeHandler: @escaping () -> Void) {
// should only call once
assert(self.changeHandler == nil)
self.changeHandler = changeHandler
self.lastSortOrder = CNContactsUserDefaults.shared().sortOrder
2017-05-18 02:24:08 +02:00
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.runNowOr {
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()
}
2017-05-18 02:24:08 +02:00
}
@objc
func runChangeHandler() {
guard let changeHandler = self.changeHandler else {
owsFail("\(TAG) trying to run change handler before it was registered")
2017-05-18 02:24:08 +02:00
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")
2017-05-18 02:24:08 +02:00
/**
* System contact fetching compatible with iOS8
*/
class AddressBookContactStoreAdaptee: ContactStoreAdaptee {
2017-05-18 02:24:08 +02:00
let TAG = "[AddressBookContactStoreAdaptee]"
private var addressBook: ABAddressBook = ABAddressBookCreateWithOptions(nil, nil).takeRetainedValue()
private var changeHandler: (() -> Void)?
let supportsContactEditing = false
2017-05-18 02:24:08 +02:00
var authorizationStatus: ContactStoreAuthorizationStatus {
2017-05-18 02:24:08 +02:00
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")
2017-05-18 02:24:08 +02:00
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)
2017-05-18 02:24:08 +02:00
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)
2017-05-18 02:24:08 +02:00
}
ABAddressBookRegisterExternalChangeCallback(addressBook, callback, nil)
}
func requestAccess(completionHandler: @escaping (Bool, Error?) -> Void) {
2017-05-18 02:24:08 +02:00
ABAddressBookRequestAccessWithCompletion(addressBook, completionHandler)
}
func fetchContacts() -> Result<[Contact], Error> {
2017-05-18 02:24:08 +02:00
// 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 {
2017-05-18 02:24:08 +02:00
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)
2017-05-18 02:24:08 +02:00
}
}
/**
* 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? {
2017-05-18 02:24:08 +02:00
guard ABPersonHasImageData(abRecord) else {
return nil
}
guard let data = ABPersonCopyImageData(abRecord)?.takeRetainedValue() else {
return nil
}
return data as Data
2017-05-18 02:24:08 +02:00
}
private func extractProperty<T>(_ propertyName: ABPropertyID) -> T? {
let value: AnyObject? = ABRecordCopyValue(self.abRecord, propertyName)?.takeRetainedValue()
return value as? T
}
2017-05-18 02:24:08 +02:00
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()
}
2017-05-18 02:24:08 +02:00
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
2017-05-18 02:24:08 +02:00
private var hasSetupObservation = false
override init() {
self.contactStoreAdapter = ContactStoreAdapter()
2017-12-07 16:33:27 +01:00
super.init()
SwiftSingletons.register(self)
}
@objc
public var supportsContactEditing: Bool {
2017-05-18 02:24:08 +02:00
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)
}
2017-05-18 02:24:08 +02:00
}
}
/**
* 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
}
2017-05-18 02:24:08 +02:00
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)
2017-05-08 23:35:18 +02:00
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
2017-10-25 15:18:27 +02:00
public func fetchOnceIfAlreadyAuthorized() {
AssertIsOnMainThread()
guard authorizationStatus == .authorized else {
return
}
2017-10-25 15:18:27 +02:00
guard !systemContactsHaveBeenRequestedAtLeastOnce else {
return
}
updateContacts(completion: nil, isUserRequested: false)
}
@objc
public func userRequestedRefresh(completion: @escaping (Error?) -> Void) {
2017-10-25 15:18:27 +02:00
AssertIsOnMainThread()
guard authorizationStatus == .authorized else {
owsFail("should have already requested contact access")
2017-10-25 15:18:27 +02:00
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
2017-05-18 02:24:08 +02:00
setupObservationIfNecessary()
DispatchQueue.global().async {
2017-10-24 16:53:15 +02:00
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
}
}