//
// C o p y r i g h t ( c ) 2 0 1 7 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
// O r i g i n a l l y b a s e d o n E P C o n t a c t s
//
// C r e a t e d b y P r a b a h a r a n E l a n g o v a n o n 1 2 / 1 0 / 1 5 .
// P a r t s C o p y r i g h t © 2 0 1 5 P r a b a h a r a n E l a n g o v a n . A l l r i g h t s r e s e r v e d
import UIKit
import Contacts
import SignalServiceKit
@ available ( iOS 9.0 , * )
public protocol ContactsPickerDelegate {
func contactsPicker ( _ : ContactsPicker , didContactFetchFailed error : NSError )
func contactsPicker ( _ : ContactsPicker , didCancel error : NSError )
func contactsPicker ( _ : ContactsPicker , didSelectContact contact : Contact )
func contactsPicker ( _ : ContactsPicker , didSelectMultipleContacts contacts : [ Contact ] )
func contactsPicker ( _ : ContactsPicker , shouldSelectContact contact : Contact ) -> Bool
}
@ available ( iOS 9.0 , * )
public extension ContactsPickerDelegate {
func contactsPicker ( _ : ContactsPicker , didContactFetchFailed error : NSError ) { }
func contactsPicker ( _ : ContactsPicker , didCancel error : NSError ) { }
func contactsPicker ( _ : ContactsPicker , didSelectContact contact : Contact ) { }
func contactsPicker ( _ : ContactsPicker , didSelectMultipleContacts contacts : [ Contact ] ) { }
func contactsPicker ( _ : ContactsPicker , shouldSelectContact contact : Contact ) -> Bool { return true }
}
public enum SubtitleCellValue {
case phoneNumber
case email
}
@ available ( iOS 9.0 , * )
open class ContactsPicker : OWSViewController , UITableViewDelegate , UITableViewDataSource , UISearchBarDelegate {
@IBOutlet var tableView : UITableView !
@IBOutlet var searchBar : UISearchBar !
// MARK: - P r o p e r t i e s
let TAG = " [ContactsPicker] "
let contactCellReuseIdentifier = " contactCellReuseIdentifier "
let contactsManager : OWSContactsManager
let collation = UILocalizedIndexedCollation . current ( )
let contactStore = CNContactStore ( )
// D a t a S o u r c e S t a t e
lazy var sections = [ [ CNContact ] ] ( )
lazy var filteredSections = [ [ CNContact ] ] ( )
lazy var selectedContacts = [ Contact ] ( )
// C o n f i g u r a t i o n
open var contactsPickerDelegate : ContactsPickerDelegate ?
var subtitleCellValue = SubtitleCellValue . phoneNumber
var multiSelectEnabled = false
let allowedContactKeys : [ CNKeyDescriptor ] = [
CNContactFormatter . descriptorForRequiredKeys ( for : . fullName ) ,
CNContactThumbnailImageDataKey as CNKeyDescriptor ,
CNContactPhoneNumbersKey as CNKeyDescriptor ,
CNContactEmailAddressesKey as CNKeyDescriptor
]
// MARK: - L i f e c y c l e M e t h o d s
override open func viewDidLoad ( ) {
super . viewDidLoad ( )
title = NSLocalizedString ( " INVITE_FRIENDS_PICKER_TITLE " , comment : " Navbar title " )
searchBar . placeholder = NSLocalizedString ( " INVITE_FRIENDS_PICKER_SEARCHBAR_PLACEHOLDER " , comment : " Search " )
// P r e v e n t c o n t e n t f o r m g o i n g u n d e r t h e n a v i g a t i o n b a r
self . edgesForExtendedLayout = [ ]
// A u t o s i z e c e l l s f o r d y n a m i c t y p e
tableView . estimatedRowHeight = 60.0
tableView . rowHeight = UITableViewAutomaticDimension
tableView . allowsMultipleSelection = multiSelectEnabled
registerContactCell ( )
initializeBarButtons ( )
reloadContacts ( )
updateSearchResults ( searchText : " " )
NotificationCenter . default . addObserver ( self , selector : #selector ( self . didChangePreferredContentSize ) , name : NSNotification . Name . UIContentSizeCategoryDidChange , object : nil )
}
func didChangePreferredContentSize ( ) {
self . tableView . reloadData ( )
}
func initializeBarButtons ( ) {
let cancelButton = UIBarButtonItem ( barButtonSystemItem : . stop , target : self , action : #selector ( onTouchCancelButton ) )
self . navigationItem . leftBarButtonItem = cancelButton
if multiSelectEnabled {
let doneButton = UIBarButtonItem ( barButtonSystemItem : . done , target : self , action : #selector ( onTouchDoneButton ) )
self . navigationItem . rightBarButtonItem = doneButton
}
}
fileprivate func registerContactCell ( ) {
tableView . register ( ContactCell . nib , forCellReuseIdentifier : contactCellReuseIdentifier )
}
// MARK: - I n i t i a l i z e r s
init ( ) {
contactsManager = Environment . current ( ) . contactsManager
super . init ( nibName : nil , bundle : nil )
}
required public init ? ( coder aDecoder : NSCoder ) {
contactsManager = Environment . current ( ) . contactsManager
super . init ( coder : aDecoder )
}
convenience public init ( delegate : ContactsPickerDelegate ? ) {
self . init ( delegate : delegate , multiSelection : false )
}
convenience public init ( delegate : ContactsPickerDelegate ? , multiSelection : Bool ) {
self . init ( )
multiSelectEnabled = multiSelection
contactsPickerDelegate = delegate
}
convenience public init ( delegate : ContactsPickerDelegate ? , multiSelection : Bool , subtitleCellType : SubtitleCellValue ) {
self . init ( )
multiSelectEnabled = multiSelection
contactsPickerDelegate = delegate
subtitleCellValue = subtitleCellType
}
// MARK: - C o n t a c t O p e r a t i o n s
open func reloadContacts ( ) {
getContacts ( onError : { error in
Logger . error ( " \( self . TAG ) failed to reload contacts with error: \( error ) " )
} )
}
func getContacts ( onError errorHandler : @ escaping ( _ error : Error ) -> Void ) {
switch CNContactStore . authorizationStatus ( for : CNEntityType . contacts ) {
case CNAuthorizationStatus . denied , CNAuthorizationStatus . restricted :
let title = NSLocalizedString ( " INVITE_FLOW_REQUIRES_CONTACT_ACCESS_TITLE " , comment : " Alert title when contacts disabled while trying to invite contacts to signal " )
let body = NSLocalizedString ( " INVITE_FLOW_REQUIRES_CONTACT_ACCESS_BODY " , comment : " Alert body when contacts disabled while trying to invite contacts to signal " )
let alert = UIAlertController ( title : title , message : body , preferredStyle : UIAlertControllerStyle . alert )
let dismissText = CommonStrings . cancelButton
let cancelAction = UIAlertAction ( title : dismissText , style : . cancel , handler : { _ in
let error = NSError ( domain : " contactsPickerErrorDomain " , code : 1 , userInfo : [ NSLocalizedDescriptionKey : " No Contacts Access " ] )
self . contactsPickerDelegate ? . contactsPicker ( self , didContactFetchFailed : error )
errorHandler ( error )
self . dismiss ( animated : true , completion : nil )
} )
alert . addAction ( cancelAction )
let settingsText = NSLocalizedString ( " OPEN_SETTINGS_BUTTON " , comment : " Button text which opens the settings app " )
let openSettingsAction = UIAlertAction ( title : settingsText , style : . default , handler : { ( _ ) in
UIApplication . shared . openSystemSettings ( )
} )
alert . addAction ( openSettingsAction )
self . present ( alert , animated : true , completion : nil )
case CNAuthorizationStatus . notDetermined :
// T h i s c a s e m e a n s t h e u s e r i s p r o m p t e d f o r t h e f i r s t t i m e f o r a l l o w i n g c o n t a c t s
contactStore . requestAccess ( for : CNEntityType . contacts ) { ( granted , error ) -> Void in
// A t t h i s p o i n t a n a l e r t i s p r o v i d e d t o t h e u s e r t o p r o v i d e a c c e s s t o c o n t a c t s . T h i s w i l l g e t i n v o k e d i f a u s e r r e s p o n d s t o t h e a l e r t
if granted {
self . getContacts ( onError : errorHandler )
} else {
errorHandler ( error ! )
}
}
case CNAuthorizationStatus . authorized :
// A u t h o r i z a t i o n g r a n t e d b y u s e r f o r t h i s a p p .
var contacts = [ CNContact ] ( )
do {
let contactFetchRequest = CNContactFetchRequest ( keysToFetch : allowedContactKeys )
try contactStore . enumerateContacts ( with : contactFetchRequest ) { ( contact , _ ) -> Void in
contacts . append ( contact )
}
self . sections = collatedContacts ( contacts )
} catch let error as NSError {
Logger . error ( " \( self . TAG ) Failed to fetch contacts with error: \( error ) " )
}
}
}
func collatedContacts ( _ contacts : [ CNContact ] ) -> [ [ CNContact ] ] {
let selector : Selector = #selector ( getter : CNContact . nameForCollating )
var collated = Array ( repeating : [ CNContact ] ( ) , count : collation . sectionTitles . count )
for contact in contacts {
let sectionNumber = collation . section ( for : contact , collationStringSelector : selector )
collated [ sectionNumber ] . append ( contact )
}
return collated
}
// MARK: - T a b l e V i e w D a t a S o u r c e
open func numberOfSections ( in tableView : UITableView ) -> Int {
return self . collation . sectionTitles . count
}
open func tableView ( _ tableView : UITableView , numberOfRowsInSection section : Int ) -> Int {
let dataSource = filteredSections
guard section < dataSource . count else {
return 0
}
return dataSource [ section ] . count
}
// MARK: - T a b l e V i e w D e l e g a t e s
open func tableView ( _ tableView : UITableView , cellForRowAt indexPath : IndexPath ) -> UITableViewCell {
let cell = tableView . dequeueReusableCell ( withIdentifier : contactCellReuseIdentifier , for : indexPath ) as ! ContactCell
let dataSource = filteredSections
let cnContact = dataSource [ indexPath . section ] [ indexPath . row ]
let contact = Contact ( systemContact : cnContact )
cell . updateContactsinUI ( contact , subtitleType : subtitleCellValue , contactsManager : self . contactsManager )
let isSelected = selectedContacts . contains ( where : { $0 . uniqueId = = contact . uniqueId } )
cell . isSelected = isSelected
// M a k e s u r e w e p r e s e r v e s e l e c t i o n a c r o s s t a b l e V i e w . r e l o a d D a t a w h i c h h a p p e n s w h e n t o g g l i n g b e t w e e n
// s e a r c h c o n t r o l l e r
if ( isSelected ) {
self . tableView . selectRow ( at : indexPath , animated : false , scrollPosition : . none )
} else {
self . tableView . deselectRow ( at : indexPath , animated : false )
}
return cell
}
open func tableView ( _ tableView : UITableView , didDeselectRowAt indexPath : IndexPath ) {
let cell = tableView . cellForRow ( at : indexPath ) as ! ContactCell
let deselectedContact = cell . contact !
selectedContacts = selectedContacts . filter {
return $0 . uniqueId != deselectedContact . uniqueId
}
}
open func tableView ( _ tableView : UITableView , didSelectRowAt indexPath : IndexPath ) {
let cell = tableView . cellForRow ( at : indexPath ) as ! ContactCell
let selectedContact = cell . contact !
guard ( contactsPickerDelegate = = nil || contactsPickerDelegate ! . contactsPicker ( self , shouldSelectContact : selectedContact ) ) else {
self . tableView . deselectRow ( at : indexPath , animated : false )
return
}
selectedContacts . append ( selectedContact )
if ! multiSelectEnabled {
// S i n g l e s e l e c t i o n c o d e
self . dismiss ( animated : true ) {
self . contactsPickerDelegate ? . contactsPicker ( self , didSelectContact : selectedContact )
}
}
}
open func tableView ( _ tableView : UITableView , sectionForSectionIndexTitle title : String , at index : Int ) -> Int {
return collation . section ( forSectionIndexTitle : index )
}
open func sectionIndexTitles ( for tableView : UITableView ) -> [ String ] ? {
return collation . sectionIndexTitles
}
open func tableView ( _ tableView : UITableView , titleForHeaderInSection section : Int ) -> String ? {
let dataSource = filteredSections
guard section < dataSource . count else {
return nil
}
// D o n ' t s h o w e m p t y s e c t i o n s
if dataSource [ section ] . count > 0 {
guard section < collation . sectionTitles . count else {
return nil
}
return collation . sectionTitles [ section ]
} else {
return nil
}
}
// MARK: - B u t t o n A c t i o n s
func onTouchCancelButton ( ) {
contactsPickerDelegate ? . contactsPicker ( self , didCancel : NSError ( domain : " contactsPickerErrorDomain " , code : 2 , userInfo : [ NSLocalizedDescriptionKey : " User Canceled Selection " ] ) )
dismiss ( animated : true , completion : nil )
}
func onTouchDoneButton ( ) {
contactsPickerDelegate ? . contactsPicker ( self , didSelectMultipleContacts : selectedContacts )
dismiss ( animated : true , completion : nil )
}
// MARK: - S e a r c h A c t i o n s
open func searchBar ( _ searchBar : UISearchBar , textDidChange searchText : String ) {
updateSearchResults ( searchText : searchText )
}
open func updateSearchResults ( searchText : String ) {
let predicate : NSPredicate
if searchText . count = = 0 {
filteredSections = sections
} else {
do {
predicate = CNContact . predicateForContacts ( matchingName : searchText )
let filteredContacts = try contactStore . unifiedContacts ( matching : predicate , keysToFetch : allowedContactKeys )
filteredSections = collatedContacts ( filteredContacts )
} catch let error as NSError {
Logger . error ( " \( self . TAG ) updating search results failed with error: \( error ) " )
}
}
self . tableView . reloadData ( )
}
}
@ available ( iOS 9.0 , * )
let ContactSortOrder = computeSortOrder ( )
@ available ( iOS 9.0 , * )
func computeSortOrder ( ) -> CNContactSortOrder {
let comparator = CNContact . comparator ( forNameSortOrder : . userDefault )
let contact0 = CNMutableContact ( )
contact0 . givenName = " A "
contact0 . familyName = " Z "
let contact1 = CNMutableContact ( )
contact1 . givenName = " Z "
contact1 . familyName = " A "
let result = comparator ( contact0 , contact1 )
if result = = . orderedAscending {
return . givenName
} else {
return . familyName
}
}
@ available ( iOS 9.0 , * )
fileprivate extension CNContact {
/* *
* Sorting Key used by collation
*/
@objc var nameForCollating : String {
get {
if self . familyName . isEmpty && self . givenName . isEmpty {
return self . emailAddresses . first ? . value as String ? ? ? " "
}
let compositeName : String
if ContactSortOrder = = . familyName {
compositeName = " \( self . familyName ) \( self . givenName ) "
} else {
compositeName = " \( self . givenName ) \( self . familyName ) "
}
return compositeName . trimmingCharacters ( in : CharacterSet . whitespacesAndNewlines )
}
}
}