swift: Add discovery & invites to the todos app
Adds advertising to the todos example app, an invite handler that
auto-accepts all invites, discovery that shows nearby users as
well as all their contacts in their address book, and the invite
logic for a given discovered or addressbook-based email.
This CL also includes the following other fixes:
- Adds a public init for SyncgroupInviteHandler in Syncbase.framework
- An initial set of app icons. We need to include more sizes for
various devices.
- Removes an assert that was invalid in Syncbase model.
Change-Id: I5912917852bcc4d0edb0caf916f79c99ceed9d5b
diff --git a/Syncbase/Source/Database.swift b/Syncbase/Source/Database.swift
index e0370ff..b6118fc 100644
--- a/Syncbase/Source/Database.swift
+++ b/Syncbase/Source/Database.swift
@@ -18,6 +18,11 @@
/// `onError` is called, no other methods will be called on this handler.
public let onError: ErrorType -> Void
+ public init(onInvite: SyncgroupInvite -> Void, onError: ErrorType -> Void) {
+ self.onInvite = onInvite
+ self.onError = onError
+ }
+
// This internal-only variable allows us to test SyncgroupInviteHandler structs for equality.
// This cannot be done otherwise as function calls cannot be tested for equality.
// Equality is important to facilitate the add/remove APIs in Database where
diff --git a/Syncbase/Source/Syncgroup.swift b/Syncbase/Source/Syncgroup.swift
index cbb8803..d788c67 100644
--- a/Syncbase/Source/Syncgroup.swift
+++ b/Syncbase/Source/Syncgroup.swift
@@ -124,11 +124,13 @@
mountTables: oldSpec.mountTables,
isPrivate: oldSpec.isPrivate),
version: versionedSpec.version))
- // TODO(sadovsky): There's a race here - it's possible for a collection to get destroyed
- // after spec.getCollections() but before db.getCollection().
- try self.database.runInBatch { db in
- for id in oldSpec.collections {
- try db.collection(Identifier(coreId: id)).updateAccessList(delta)
+ if !syncgroupOnly {
+ // TODO(sadovsky): There's a race here - it's possible for a collection to get destroyed
+ // after spec.getCollections() but before db.getCollection().
+ try self.database.runInBatch { db in
+ for id in oldSpec.collections {
+ try db.collection(Identifier(coreId: id)).updateAccessList(delta)
+ }
}
}
}
diff --git a/Syncbase/Tests/BasicDatabaseTests.swift b/Syncbase/Tests/BasicDatabaseTests.swift
index 4ef5fd4..9351a2d 100644
--- a/Syncbase/Tests/BasicDatabaseTests.swift
+++ b/Syncbase/Tests/BasicDatabaseTests.swift
@@ -242,7 +242,7 @@
XCTAssertFalse(didChange)
XCTAssertEqual(changes.count, 1)
let change = changes.first!
- XCTAssertEqual(change.row, try? Syncbase.UserdataCollectionPrefix + sg1.encode())
+ XCTAssertEqual(change.row, try? Syncbase.UserdataInternalSyncgroupPrefix + sg1.encode())
XCTAssertEqual(change.changeType, WatchChange.ChangeType.Put)
XCTAssertEqual(change.value, NSData())
didChange = true
@@ -295,7 +295,7 @@
XCTAssertFalse(changes.isEmpty)
for change in changes {
XCTAssert(change.collectionId?.name == Syncbase.UserdataSyncgroupName)
- XCTAssert(change.row?.hasPrefix(Syncbase.UserdataCollectionPrefix) ?? false)
+ XCTAssert(change.row?.hasPrefix(Syncbase.UserdataInternalPrefix) ?? false)
}
dispatch_semaphore_signal(initialSemaphore)
},
diff --git a/SyncbaseTodosApp/SyncbaseTodos.xcodeproj/project.pbxproj b/SyncbaseTodosApp/SyncbaseTodos.xcodeproj/project.pbxproj
index eb728da..2ac21dc 100644
--- a/SyncbaseTodosApp/SyncbaseTodos.xcodeproj/project.pbxproj
+++ b/SyncbaseTodosApp/SyncbaseTodos.xcodeproj/project.pbxproj
@@ -14,7 +14,7 @@
37B018CC1CFF7A9D004B0A15 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 37B018BF1CFF7A9D004B0A15 /* Main.storyboard */; };
37B018CD1CFF7A9D004B0A15 /* CircularImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B018C11CFF7A9D004B0A15 /* CircularImageView.swift */; };
37B018CF1CFF7A9D004B0A15 /* MemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B018C31CFF7A9D004B0A15 /* MemberView.swift */; };
- 37B018D01CFF7A9D004B0A15 /* Person.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B018C41CFF7A9D004B0A15 /* Person.swift */; };
+ 37B018D01CFF7A9D004B0A15 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B018C41CFF7A9D004B0A15 /* Contact.swift */; };
37B018D11CFF7A9D004B0A15 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B018C51CFF7A9D004B0A15 /* Task.swift */; };
37B018D21CFF7A9D004B0A15 /* TasksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B018C61CFF7A9D004B0A15 /* TasksViewController.swift */; };
37B018D31CFF7A9D004B0A15 /* TodoList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B018C71CFF7A9D004B0A15 /* TodoList.swift */; };
@@ -112,7 +112,7 @@
37B018C11CFF7A9D004B0A15 /* CircularImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularImageView.swift; sourceTree = "<group>"; };
37B018C21CFF7A9D004B0A15 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
37B018C31CFF7A9D004B0A15 /* MemberView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MemberView.swift; sourceTree = "<group>"; };
- 37B018C41CFF7A9D004B0A15 /* Person.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Person.swift; sourceTree = "<group>"; };
+ 37B018C41CFF7A9D004B0A15 /* Contact.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = "<group>"; };
37B018C51CFF7A9D004B0A15 /* Task.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
37B018C61CFF7A9D004B0A15 /* TasksViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TasksViewController.swift; sourceTree = "<group>"; };
37B018C71CFF7A9D004B0A15 /* TodoList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TodoList.swift; sourceTree = "<group>"; };
@@ -197,7 +197,7 @@
37B018D51CFF7B48004B0A15 /* Model */ = {
isa = PBXGroup;
children = (
- 37B018C41CFF7A9D004B0A15 /* Person.swift */,
+ 37B018C41CFF7A9D004B0A15 /* Contact.swift */,
37B018C51CFF7A9D004B0A15 /* Task.swift */,
37B018C71CFF7A9D004B0A15 /* TodoList.swift */,
932AD50D1D1BCFD3000EA038 /* Marshal.swift */,
@@ -398,7 +398,7 @@
37B018C91CFF7A9D004B0A15 /* AppDelegate.swift in Sources */,
37B018D11CFF7A9D004B0A15 /* Task.swift in Sources */,
37B018D41CFF7A9D004B0A15 /* TodosViewController.swift in Sources */,
- 37B018D01CFF7A9D004B0A15 /* Person.swift in Sources */,
+ 37B018D01CFF7A9D004B0A15 /* Contact.swift in Sources */,
37B018CD1CFF7A9D004B0A15 /* CircularImageView.swift in Sources */,
932AD50E1D1BCFD3000EA038 /* Marshal.swift in Sources */,
93213FEF1D1C8BB100297838 /* Syncbase.swift in Sources */,
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/Contents.json b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/Contents.json
index 118c98f..e4e9d38 100644
--- a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -21,13 +21,15 @@
"scale" : "3x"
},
{
- "idiom" : "iphone",
"size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "app-icon@2x.png",
"scale" : "2x"
},
{
- "idiom" : "iphone",
"size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "app-icon@3x.png",
"scale" : "3x"
}
],
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/app-icon@2x.png b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/app-icon@2x.png
new file mode 100644
index 0000000..19eaa87
--- /dev/null
+++ b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/app-icon@2x.png
Binary files differ
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/app-icon@3x.png b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/app-icon@3x.png
new file mode 100644
index 0000000..7f064bf
--- /dev/null
+++ b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/AppIcon.appiconset/app-icon@3x.png
Binary files differ
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/profilePhoto.imageset/Contents.json b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/profilePhoto.imageset/Contents.json
deleted file mode 100644
index d7d9e61..0000000
--- a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/profilePhoto.imageset/Contents.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "images" : [
- {
- "idiom" : "universal",
- "scale" : "1x"
- },
- {
- "idiom" : "universal",
- "filename" : "profilePhoto.png",
- "scale" : "2x"
- },
- {
- "idiom" : "universal",
- "scale" : "3x"
- }
- ],
- "info" : {
- "version" : 1,
- "author" : "xcode"
- }
-}
\ No newline at end of file
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/profilePhoto.imageset/profilePhoto.png b/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/profilePhoto.imageset/profilePhoto.png
deleted file mode 100644
index d68d016..0000000
--- a/SyncbaseTodosApp/SyncbaseTodos/Assets.xcassets/profilePhoto.imageset/profilePhoto.png
+++ /dev/null
Binary files differ
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Base.lproj/Main.storyboard b/SyncbaseTodosApp/SyncbaseTodos/Base.lproj/Main.storyboard
index 48add4c..75202bf 100644
--- a/SyncbaseTodosApp/SyncbaseTodos/Base.lproj/Main.storyboard
+++ b/SyncbaseTodosApp/SyncbaseTodos/Base.lproj/Main.storyboard
@@ -180,12 +180,6 @@
</connections>
</barButtonItem>
<barButtonItem style="plain" systemItem="flexibleSpace" id="tGB-vG-wwH"/>
- <barButtonItem title="Debug" id="M4k-N2-NVt">
- <connections>
- <action selector="debug" destination="4Az-YS-hA9" id="zFH-Wp-hcs"/>
- </connections>
- </barButtonItem>
- <barButtonItem style="plain" systemItem="flexibleSpace" id="fCt-vQ-eNa"/>
</items>
</toolbar>
</subviews>
@@ -225,6 +219,7 @@
<outlet property="menuToolbar" destination="pgB-6n-kxg" id="t4H-IG-sSd"/>
<outlet property="menuToolbarTopConstraint" destination="Tte-CX-3hG" id="TUD-bG-KpB"/>
<outlet property="tableView" destination="kmv-xQ-8U6" id="88K-fx-Dzp"/>
+ <outlet property="toggleSharingButton" destination="rb7-ge-A8E" id="DiM-k8-cmh"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="jFT-NN-2Df" userLabel="First Responder" sceneMemberID="firstResponder"/>
@@ -418,7 +413,7 @@
<scene sceneID="KB2-7v-cHC">
<objects>
<tableViewController id="TEb-eN-UhB" customClass="InviteViewController" customModule="SyncbaseTodos" customModuleProvider="target" sceneMemberID="viewController">
- <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="74" sectionHeaderHeight="28" sectionFooterHeight="28" id="p4P-W4-gFc">
+ <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" rowHeight="74" sectionHeaderHeight="28" sectionFooterHeight="28" id="p4P-W4-gFc">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
@@ -427,42 +422,32 @@
<rect key="frame" x="0.0" y="92" width="600" height="74"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="SrI-gO-iqH" id="07a-cu-tgk">
- <rect key="frame" x="0.0" y="0.0" width="600" height="74"/>
+ <rect key="frame" x="0.0" y="0.0" width="600" height="73.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
- <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="profilePhoto" translatesAutoresizingMaskIntoConstraints="NO" id="ZNe-Xc-Gii" customClass="CircularImageView" customModule="SyncbaseTodos" customModuleProvider="target">
- <rect key="frame" x="18" y="18" width="38" height="38"/>
- <constraints>
- <constraint firstAttribute="width" secondItem="ZNe-Xc-Gii" secondAttribute="height" multiplier="1:1" id="X6H-qC-G5F"/>
- </constraints>
- </imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Xw3-gj-ST1">
- <rect key="frame" x="71" y="8" width="521" height="58"/>
+ <rect key="frame" x="18" y="8" width="574" height="58"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
- <constraint firstItem="Xw3-gj-ST1" firstAttribute="leading" secondItem="ZNe-Xc-Gii" secondAttribute="trailing" constant="15" id="5sy-T3-58g"/>
- <constraint firstItem="ZNe-Xc-Gii" firstAttribute="top" secondItem="07a-cu-tgk" secondAttribute="topMargin" constant="10" id="60n-Wl-qqy"/>
<constraint firstItem="Xw3-gj-ST1" firstAttribute="top" secondItem="07a-cu-tgk" secondAttribute="topMargin" id="Gn4-CV-9rE"/>
- <constraint firstAttribute="bottomMargin" secondItem="ZNe-Xc-Gii" secondAttribute="bottom" constant="10" id="TT7-Ae-akW"/>
<constraint firstAttribute="bottomMargin" secondItem="Xw3-gj-ST1" secondAttribute="bottom" id="X3K-9W-Xr0"/>
<constraint firstAttribute="trailingMargin" secondItem="Xw3-gj-ST1" secondAttribute="trailing" id="bQu-ls-rnE"/>
- <constraint firstItem="ZNe-Xc-Gii" firstAttribute="leading" secondItem="07a-cu-tgk" secondAttribute="leadingMargin" constant="10" id="z5g-zm-qTP"/>
+ <constraint firstItem="Xw3-gj-ST1" firstAttribute="leading" secondItem="07a-cu-tgk" secondAttribute="leadingMargin" constant="10" id="fkA-2x-qZ2"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="nameLabel" destination="Xw3-gj-ST1" id="lvu-cq-AQB"/>
- <outlet property="photoImageView" destination="ZNe-Xc-Gii" id="Pxf-OI-ks4"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="sendEmailCellId" id="fBZ-PZ-K1C" userLabel="SendEmailCell" customClass="SendEmailCell" customModule="SyncbaseTodos" customModuleProvider="target">
<rect key="frame" x="0.0" y="166" width="600" height="74"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="fBZ-PZ-K1C" id="II9-ei-hFs">
- <rect key="frame" x="0.0" y="0.0" width="600" height="74"/>
+ <rect key="frame" x="0.0" y="0.0" width="600" height="73.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="sendEmail" translatesAutoresizingMaskIntoConstraints="NO" id="BFR-NP-pD9">
@@ -527,7 +512,6 @@
</scenes>
<resources>
<image name="checkmarkOff" width="24" height="24"/>
- <image name="profilePhoto" width="88" height="88"/>
<image name="sendEmail" width="88" height="88"/>
<image name="v23-icon-white" width="320" height="320"/>
</resources>
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Contact.swift b/SyncbaseTodosApp/SyncbaseTodos/Contact.swift
new file mode 100644
index 0000000..6309cf5
--- /dev/null
+++ b/SyncbaseTodosApp/SyncbaseTodos/Contact.swift
@@ -0,0 +1,52 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+import Contacts
+import Foundation
+import Syncbase
+
+final class Contact: CustomStringConvertible {
+ static let keysToFetch = [
+ CNContactFormatter.descriptorForRequiredKeysForStyle(.FullName),
+ CNContactEmailAddressesKey,
+ CNContactImageDataAvailableKey]
+
+ var name: String?
+ var emails: [String]?
+ var imageURL: NSURL?
+ var user: User?
+
+ init(name: String? = nil, emails: [String]? = nil, imageURL: NSURL? = nil, user: User? = nil) {
+ self.name = name
+ self.emails = emails
+ self.user = user
+ self.imageURL = imageURL
+ }
+
+ init (contact: CNContact) {
+ name = CNContactFormatter.stringFromContact(contact, style: .FullName)
+ // Note there are very rare occasions where an email address may be case-sensitive. You may want
+ // to account for this in your app accordingly.
+ emails = contact.emailAddresses.map { ($0.value as! String).lowercaseString }
+ if contact.imageDataAvailable {
+ imageURL = NSURL(string: "contact://image/\(contact.identifier))")
+ }
+ }
+
+ var description: String {
+ if let name = name {
+ if let user = user {
+ // Distinguish the contact better by providing the advertising alias (email) for context.
+ return "\(name) - \(user.alias)"
+ }
+ return name
+ } else if let user = user {
+ return user.alias
+ } else if let emails = emails where !emails.isEmpty {
+ return emails.first!
+ } else {
+ return ""
+ }
+ }
+}
\ No newline at end of file
diff --git a/SyncbaseTodosApp/SyncbaseTodos/InviteViewController.swift b/SyncbaseTodosApp/SyncbaseTodos/InviteViewController.swift
index 78470a0..0d6e00d 100644
--- a/SyncbaseTodosApp/SyncbaseTodos/InviteViewController.swift
+++ b/SyncbaseTodosApp/SyncbaseTodos/InviteViewController.swift
@@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+import Contacts
+import ContactsUI
+import Syncbase
import UIKit
enum Category: Int {
@@ -12,25 +15,63 @@
}
class InviteViewController: UITableViewController {
+ // Represents the search bar.
var searchController: UISearchController!
- let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
- let personCellId = "personCellId"
- let sendEmailCell = "sendEmailCellId"
- var shouldDismiss = false
- var people: [[Person]] = [[], [], []]
+ // All known people -- this is the model that the tableview loads from.
+ var people: [[Contact]] = [[], [], []]
+ // Allows us to look up address book contacts we might discover via BLE/mDNS.
+ var contactsByLowercaseEmail: [String: Contact] = [:]
+ var contactsByLowercaseEmailMu = NSLock()
+ // True if the search controller's text matches any existing contacts.
var searchResultsFound = false
- var searchResults: [[Person]] = [[], [], []]
- let sectionNames: [Category: String] = [
+ // All matching people for a given search -- this is the model the tableview loads from.
+ var searchResults: [[Contact]] = [[], [], []]
+ // True if contacts were able to be loaded from the iOS Address Book.
+ var didLoadContacts = false
+ // The TodoList we are inviting users to.
+ var todoList: TodoList!
+
+ // Regex used to know when a search represents an email address.
+ let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
+ // The cell that displays people's names or emails.
+ let personCellId = "personCellId"
+ // The cell that represents sending an invite directly to an email address.
+ let sendEmailCell = "sendEmailCellId"
+ // The names of each section of the table view.
+ var sectionNames: [Category: String] = [
.NearbyContacts: "Nearby Contacts",
.Nearby: "Nearby",
.Contacts: "Contacts",
]
+ // Set to true if the user clicked on a name to invite while the seearch controller was active.
+ // The invite logic first waits for the search controller to turn inactive before the view
+ // controller is dismissed. This variable keeps track for the didDismissSearchController callback
+ // to know if it should dismiss the VC or not.
+ var shouldDismiss = false
override func viewDidLoad() {
super.viewDidLoad()
+ guard todoList.collection != nil else {
+ print("Missing collection from todo list \(todoList)")
+ // Pop view since we can't invite a user. This must be done outside of the viewDidLoad.
+ dispatch_async(dispatch_get_main_queue()) {
+ self.dismissViewControllerAnimated(true) { }
+ }
+ return
+ }
initSearchController()
-// createFakeData()
-// tableView.reloadData()
+ loadContacts() {
+ do {
+ try Syncbase.startScanForUsersInNeighborhood(
+ ScanNeighborhoodForUsersHandler(onFound: self.onFound, onLost: self.onLost))
+ } catch {
+ print("Unable to scan for other users: \(error)")
+ }
+ }
+ }
+
+ override func viewWillDisappear(animated: Bool) {
+ Syncbase.stopAllScansForUsersInNeighborhood()
}
func initSearchController() {
@@ -44,30 +85,63 @@
definesPresentationContext = true
}
-// func createFakeData() {
-// // TODO(azinman): Remove.
-// people.insert([
-// Person(name: "Lady Rainicorn", imageName: "profilePhoto"),
-// Person(name: "Princess Bubblegum", imageName: "profilePhoto"),
-// Person(name: "Ice King", imageName: "profilePhoto")
-// ],
-// atIndex: Category.NearbyContacts.rawValue
-// )
-//
-// people.insert([
-// Person(name: "Gunter", imageName: "profilePhoto"),
-// Person(name: "dayang@google.com", imageName: "profilePhoto"),
-// Person(name: "Tom", imageName: "profilePhoto"),
-// ],
-// atIndex: Category.Nearby.rawValue
-// )
-//
-// people.insert([
-// Person(name: "Lady Rainicorn", imageName: "profilePhoto"),
-// ],
-// atIndex: Category.Contacts.rawValue
-// )
-// }
+ // MARK: Address Book contacts
+
+ func loadContacts(doneCallback: Void -> Void) {
+ CNContactStore().requestAccessForEntityType(.Contacts) { (granted, error) in
+ guard granted && error == nil else {
+ print("Contants not able to load: granted=\(granted) error=\(error)")
+ dispatch_async(dispatch_get_main_queue()) {
+ self.disableContacts()
+ doneCallback()
+ }
+ return
+ }
+ let request = CNContactFetchRequest(keysToFetch: Contact.keysToFetch)
+ var results: [Contact] = []
+ do {
+ self.contactsByLowercaseEmailMu.lock()
+ defer { self.contactsByLowercaseEmailMu.unlock() }
+ // This is a bit slow loading everything at once. In a real app you want to take a more
+ // advanced strategy, perhaps updating the UI in batches or some other methodology.
+ try CNContactStore().enumerateContactsWithFetchRequest(request) { (cncontact, stop) in
+ let contact = Contact(contact: cncontact)
+ results.append(contact)
+ if let emails = contact.emails {
+ for email in emails {
+ // The email was lowercased in the Contact class's init.
+ self.contactsByLowercaseEmail[email] = contact
+ }
+ }
+ }
+ dispatch_async(dispatch_get_main_queue()) {
+ self.didLoadContacts = true
+ self.people[Category.Contacts.rawValue] = results
+ self.tableView.reloadData()
+ doneCallback()
+ }
+ } catch {
+ print("Unable to fetch contacts: \(error)")
+ dispatch_async(dispatch_get_main_queue()) {
+ self.disableContacts()
+ doneCallback()
+ }
+ }
+ }
+ }
+
+ /// disableContacts is used when the user has not granted authorization to contacts or some other
+ /// error has occured loading contacts. Must be called from main.
+ func disableContacts() {
+ didLoadContacts = false
+ sectionNames[.Contacts] = nil
+ sectionNames[.NearbyContacts] = nil
+ people = [people[Category.Nearby.rawValue]]
+ searchResults = [searchResults[Category.Nearby.rawValue]]
+ tableView.reloadData()
+ }
+
+ // MARK: Tableview delegates
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
if searchController.active && !searchResultsFound {
@@ -114,19 +188,39 @@
let cell = self.tableView.dequeueReusableCellWithIdentifier(personCellId, forIndexPath: indexPath) as! PersonCell
if searchController.active {
// Show search results.
- cell.person = searchResults[indexPath.section][indexPath.row]
+ cell.contact = searchResults[indexPath.section][indexPath.row]
} else {
// Not searching, show entire list.
- cell.person = people[indexPath.section][indexPath.row]
+ cell.contact = people[indexPath.section][indexPath.row]
}
cell.updateView()
return cell
}
+ override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
+ if searchController.active && !searchResultsFound {
+ return SendEmailCell.height
+ }
+ return PersonCell.height
+ }
+
+ // MARK: Inviting
+
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
// No-op if can't create a person from index path.
- if let personToInvite = personToInvite(indexPath) {
- invitePerson(personToInvite)
+ if let contact = contactAtIndexPath(indexPath) {
+ do {
+ try inviteContact(contact)
+ } catch {
+ print("Unable to invite \(contact): \(error)")
+ let ac = UIAlertController(
+ title: "Oops!",
+ message: "Unable to send invite. Try again.",
+ preferredStyle: .Alert)
+ ac.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil))
+ presentViewController(ac, animated: true, completion: nil)
+ return
+ }
// Dismiss the view.
if searchController.active {
// The search bar needs to close before we pop, so handle in didDismissSearch callback.
@@ -138,19 +232,14 @@
}
}
- func invitePerson(person: Person) {
- NSLog("Inviting \(person)")
- // TODO(azinman): fill in.
- }
-
- func personToInvite(indexPath: NSIndexPath) -> Person? {
+ func contactAtIndexPath(indexPath: NSIndexPath) -> Contact? {
// Determine if we entered an email or not. If email, validate email and construct a Person obj.
- var personToInvite: Person?
+ var personToInvite: Contact?
if let text = searchController.searchBar.text where searchController.active && !searchResultsFound {
let validEmail = NSPredicate(format: "SELF MATCHES %@", emailRegEx).evaluateWithObject(text)
if validEmail {
// Create new person with this email.
- personToInvite = Person(name: "", imageRef: "", email: text)
+ personToInvite = Contact(emails: [text])
} else {
// Invalid email, show alert.
let alert = UIAlertController(title: "Invalid email",
@@ -160,13 +249,14 @@
navigationController?.presentViewController(alert, animated: true, completion: nil)
}
} else if people.indices.contains(indexPath.section) &&
- people[indexPath.row].indices.contains(indexPath.row) {
+ people[indexPath.section].indices.contains(indexPath.row) {
// We're inviting an existing Person.
personToInvite = people[indexPath.section][indexPath.row]
}
return personToInvite
}
+ // This is called after selecting a contact if the search controller is active.
func didDismissSearchController(searchController: UISearchController) {
// If we were searching when we selected a person, we need to wait for its dismiss animation
// to finish before popping the view.
@@ -174,45 +264,120 @@
navigationController?.popViewControllerAnimated(true)
}
}
+
+ // inviteContact is the function responsible for actually inviting a user to the collection's
+ // syncgroup.
+ func inviteContact(contact: Contact) throws {
+ if let user = contact.user {
+ // Admin allows them to invite people to the syncgroup as well.
+ try todoList.collection!.syncgroup().inviteUser(user, level: .READ_WRITE_ADMIN)
+ } else if let emails = contact.emails {
+ for email in emails {
+ try todoList.collection!.syncgroup().inviteUser(User(alias: email), level: .READ_WRITE_ADMIN)
+ }
+ }
+ }
+
+ // MARK: Discovery
+
+ func onFound(user: User) {
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0)) {
+ // Determine if they're an existing contact.
+ self.contactsByLowercaseEmailMu.lock()
+ defer { self.contactsByLowercaseEmailMu.unlock() }
+ var contact = Contact(user: user)
+ var section = Category.Nearby.rawValue
+ if let existingContact = self.contactsByLowercaseEmail[user.alias.lowercaseString] {
+ contact = existingContact
+ contact.user = user
+ section = Category.NearbyContacts.rawValue
+ }
+ // If we were never able to load contacts, then they should be disabled and we only have 1
+ // section.
+ if !self.didLoadContacts {
+ section = 0
+ }
+ dispatch_async(dispatch_get_main_queue()) {
+ self.people[section].append(contact)
+ self.people[section].sortInPlace { (lhs, rhs) -> Bool in
+ return lhs.description.compare(rhs.description) == NSComparisonResult.OrderedAscending
+ }
+ self.tableView.reloadSections(NSIndexSet(index: section), withRowAnimation: .Automatic)
+ }
+ }
+ }
+
+ func onLost(user: User) {
+ for section in nearbySections {
+ if let idx = people[section].indexOf({ $0.user == user }) {
+ people[section].removeAtIndex(idx)
+ tableView.reloadSections(NSIndexSet(index: section), withRowAnimation: .Automatic)
+ break
+ }
+ }
+ }
+
+ var nearbySections: [Int] {
+ if didLoadContacts {
+ return [Category.NearbyContacts.rawValue, Category.Nearby.rawValue]
+ }
+ return [0]
+ }
}
+// MARK: Search
+
// Filters people to match the text user searched for.
extension InviteViewController: UISearchControllerDelegate, UISearchResultsUpdating {
func updateSearchResultsForSearchController(searchController: UISearchController) {
- // Start with all people, then filter.
- searchResults = people
-
if let searchText = searchController.searchBar.text where searchText.characters.count > 0 {
- for category in Category.allRawValues {
- searchResults[category] = searchResults[category].filter { person -> Bool in
- return person.name.rangeOfString(searchText,
+ for section in nearbySections {
+ searchResults[section] = people[section].filter { contact -> Bool in
+ return contact.description.rangeOfString(searchText,
options: .CaseInsensitiveSearch,
- range: person.name.startIndex ..< person.name.endIndex,
+ range: contact.description.startIndex ..< contact.description.endIndex,
locale: nil) != nil
}
}
+ if didLoadContacts {
+ searchResults[Category.Contacts.rawValue] = []
+ if let contacts = try? CNContactStore().unifiedContactsMatchingPredicate(
+ CNContact.predicateForContactsMatchingName(searchText),
+ keysToFetch: Contact.keysToFetch) {
+ searchResults[Category.Contacts.rawValue] = contacts.map { Contact(contact: $0) }
+ }
+ }
+ } else {
+ for i in 0 ..< searchResults.count {
+ searchResults[i] = []
+ }
}
-
- searchResultsFound = searchResults.flatten().count > 0
+ searchResultsFound = false
+ for section in searchResults {
+ if !section.isEmpty {
+ searchResultsFound = true
+ break
+ }
+ }
tableView.reloadData()
}
}
+// MARK: Tableview Cells
+
// Displays a person with name and their profile photo.
class PersonCell: UITableViewCell {
- @IBOutlet weak var photoImageView: UIImageView!
+ static let height: CGFloat = 44
@IBOutlet weak var nameLabel: UILabel!
- var person: Person?
+ var contact: Contact?
func updateView() {
- nameLabel.text = person?.name
-// if let imageName = person?.imageName {
-// photoImageView.image = UIImage(named: imageName)
-// }
+ nameLabel.text = contact?.description
}
}
// Display a cell that shows the email the invite will be sent to.
class SendEmailCell: UITableViewCell {
+ static let height: CGFloat = 74
@IBOutlet weak var emailLabel: UILabel!
}
\ No newline at end of file
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Person.swift b/SyncbaseTodosApp/SyncbaseTodos/Person.swift
deleted file mode 100644
index 96c101c..0000000
--- a/SyncbaseTodosApp/SyncbaseTodos/Person.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright 2016 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-import Foundation
-
-final class Person: Jsonable {
- var name: String = ""
- var imageRef: String = ""
- var email: String = ""
-
- init(name: String, imageRef: String, email: String) {
- self.name = name
- self.imageRef = imageRef
- self.email = email
- }
-
- func toJsonable() -> [String: AnyObject] {
- return ["name": name, "imageRef": imageRef, "email": email]
- }
-
- static func fromJsonable(data: [String: AnyObject]) -> Person? {
- guard let name = data["name"] as? String,
- imageRef = data["imageRef"] as? String,
- email = data["email"] as? String else {
- return nil
- }
- return Person(name: name, imageRef: imageRef, email: email)
- }
-}
\ No newline at end of file
diff --git a/SyncbaseTodosApp/SyncbaseTodos/Syncbase.swift b/SyncbaseTodosApp/SyncbaseTodos/Syncbase.swift
index 44c8a83..11c3c36 100644
--- a/SyncbaseTodosApp/SyncbaseTodos/Syncbase.swift
+++ b/SyncbaseTodosApp/SyncbaseTodos/Syncbase.swift
@@ -80,6 +80,34 @@
dispatch.unwatch(eventHandler)
}
+// MARK: Invites
+
+private var inviteHandler: SyncgroupInviteHandler?
+
+func startInviteHandler() throws {
+ if let handler = inviteHandler {
+ try Syncbase.database().removeSyncgroupInviteHandler(handler)
+ inviteHandler = nil
+ }
+ let handler = SyncgroupInviteHandler(
+ onInvite: { (invite: SyncgroupInvite) in
+ // Accept all invites automatically.
+ print("Accepting invite \(invite)")
+ try! Syncbase.database().acceptSyncgroupInvite(invite, callback: { (sg, err) in
+ if let err = err {
+ print("Unable to accept invite: \(err)")
+ } else {
+ print("Accepted invite into syncgroup \(sg)")
+ }
+ })
+ },
+ onError: { (err: ErrorType) in
+ print("Unable to accept invites: \(err)")
+ })
+ try Syncbase.database().addSyncgroupInviteHandler(handler)
+ inviteHandler = handler
+}
+
// MARK: Watch low-level events
private var watchHandler: WatchChangeHandler?
@@ -177,7 +205,6 @@
// Process task changes
for change in changes {
- assert(firstChange.row != todoListKey)
if let task = change.toTask() {
// Is it an update or an insert?
if let taskIdx = list.tasks.indexOf({ $0.key == task.key }) {
@@ -200,7 +227,7 @@
dispatch.notify(events)
}
-// MARK: MODEL API
+// MARK: Model API
func addList(list: TodoList) throws {
let data = try NSJSONSerialization.dataWithJSONObject(list.toJsonable(), options: [])
diff --git a/SyncbaseTodosApp/SyncbaseTodos/TasksViewController.swift b/SyncbaseTodosApp/SyncbaseTodos/TasksViewController.swift
index c079601..f1ec924 100644
--- a/SyncbaseTodosApp/SyncbaseTodos/TasksViewController.swift
+++ b/SyncbaseTodosApp/SyncbaseTodos/TasksViewController.swift
@@ -40,9 +40,16 @@
}
func onEvents(events: [ModelEvent]) {
+ // TODO(zinman): Make this more fine-grained
print("got events: \(events)")
tableView.reloadData()
}
+
+ override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
+ if let ivc = segue.destinationViewController as? InviteViewController {
+ ivc.todoList = todoList
+ }
+ }
}
/// Handles tableview functionality, including rendering and swipe actions. Tap actions are
diff --git a/SyncbaseTodosApp/SyncbaseTodos/TodoList.swift b/SyncbaseTodosApp/SyncbaseTodos/TodoList.swift
index 74cc28c..1b4f8f1 100644
--- a/SyncbaseTodosApp/SyncbaseTodos/TodoList.swift
+++ b/SyncbaseTodosApp/SyncbaseTodos/TodoList.swift
@@ -10,7 +10,7 @@
var collection: Collection? = nil
var name: String = ""
var updatedAt: NSDate = NSDate()
- var members: [Person] = []
+ var members: [Contact] = []
var tasks: [Task] = []
init(name: String, updatedAt: NSDate = NSDate()) {
@@ -43,4 +43,4 @@
}
return TodoList(name: name, updatedAt: NSDate(timeIntervalSince1970: updatedAt))
}
-}
+}
\ No newline at end of file
diff --git a/SyncbaseTodosApp/SyncbaseTodos/TodosViewController.swift b/SyncbaseTodosApp/SyncbaseTodos/TodosViewController.swift
index 27b7b50..38f27f6 100644
--- a/SyncbaseTodosApp/SyncbaseTodos/TodosViewController.swift
+++ b/SyncbaseTodosApp/SyncbaseTodos/TodosViewController.swift
@@ -9,6 +9,7 @@
class TodosViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var addButton: UIBarButtonItem!
+ @IBOutlet weak var toggleSharingButton: UIBarButtonItem!
// The menu toolbar is shown when the "edit" navigation bar is pressed.
@IBOutlet weak var menuToolbar: UIToolbar!
@IBOutlet weak var menuToolbarTopConstraint: NSLayoutConstraint!
@@ -21,11 +22,20 @@
override func viewDidLoad() {
super.viewDidLoad()
- // Hide the bottom menu by default
+ // Hide the bottom menu by default.
menuToolbarTopConstraint.constant = -menuToolbar.frame.size.height
handler = ModelEventHandler(onEvents: onEvents)
do {
try startWatching()
+ try startInviteHandler()
+ // Turn sharing (advertising) on by default, and ensure the text reflects the current initial
+ // state properly.
+ if Syncbase.isAdvertisingPresenceInNeighborhood() {
+ toggleSharingButton.title = "Turn sharing off"
+ } else {
+ toggleSharingButton.title = "Turn sharing on"
+ toggleSharing()
+ }
} catch {
print("Unable to start watch: \(error)")
}
@@ -145,7 +155,7 @@
})
ac.addAction(UIAlertAction(title: "Cancel", style: UIAlertActionStyle.Cancel, handler: nil))
ac.addTextFieldWithConfigurationHandler { $0.placeholder = "My New List" }
- self.presentViewController(ac, animated: true, completion: nil)
+ presentViewController(ac, animated: true, completion: nil)
}
func addTodoList(name: String) {
@@ -163,12 +173,30 @@
}
}
- @IBAction func debug() {
- // TODO(azinman): Make real
- }
-
@IBAction func toggleSharing() {
- // TODO(azinman): Make real
+ let isAdvertising = Syncbase.isAdvertisingPresenceInNeighborhood()
+ do {
+ if isAdvertising {
+ try Syncbase.stopAdvertisingPresenceInNeighborhood()
+ toggleSharingButton.title = "Turn sharing on"
+ } else {
+ // Advertise to everyone (we could optionally pick specific users, but no need for this app).
+ try Syncbase.startAdvertisingPresenceInNeighborhood()
+ toggleSharingButton.title = "Turn sharing off"
+ }
+ } catch {
+ var message = "turning off sharing"
+ if !isAdvertising {
+ message = "turning on sharing"
+ }
+ print("Error \(message): \(error)")
+ let ac = UIAlertController(
+ title: "Oops!",
+ message: "Error \(message). Try again.",
+ preferredStyle: .Alert)
+ ac.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil))
+ presentViewController(ac, animated: true, completion: nil)
+ }
}
func completeAllTasks(indexPath: NSIndexPath) {