swift: Improve HLAPI invite handler experience

This CL implements vanadium/issues#1408 for Swift, which hides
unnecessary invites from already joined syncgroups, including
userdata. It also auto-joins syncgroups with the user's blessings.

Change-Id: Iac59cc1683aed636a3506c1f66dc1d41efefaa06
diff --git a/Syncbase/Source/Database.swift b/Syncbase/Source/Database.swift
index 0f79ac9..3e28791 100644
--- a/Syncbase/Source/Database.swift
+++ b/Syncbase/Source/Database.swift
@@ -86,10 +86,14 @@
   // the original reference to Database). Instead, we store the databaseId to make sure that any two
   // Database instances generated from Syncbase.database behave the same with respect to
   // adding/removing watch handlers.
-  private static let syncgroupInviteHandlersMu = NSLock()
-  private static let watchChangeHandlersMu = NSLock()
-  private static var syncgroupInviteHandlers: [SyncgroupInviteHandler: SyncbaseCore.SyncgroupInvitesScanHandler] = [:]
-  private static var watchChangeHandlers: [WatchChangeHandler: WatchOperation] = [:]
+  static var watchChangeHandlers: [WatchChangeHandler: WatchOperation] = [:]
+  static let watchChangeHandlersMu = NSLock()
+  static var syncgroupInviteHandlers: [SyncgroupInviteHandler: SyncbaseCore.SyncgroupInvitesScanHandler] = [:]
+  static let syncgroupInviteHandlersMu = NSLock()
+  // These are the same as above except reserved for internal use only. If we used the dictionaries
+  // above then the removeAll* functions would remove the internal handlers.
+  static var internalWatchChangeHandlers: [WatchChangeHandler: WatchOperation] = [:]
+  static let internalWatchChangeHandlersMu = NSLock()
 
   func createIfMissing() throws {
     do {
@@ -210,18 +214,34 @@
 
   /// Notifies `handler` of any existing syncgroup invites, and of all subsequent new invites.
   public func addSyncgroupInviteHandler(handler: SyncgroupInviteHandler) {
-    Database.syncgroupInviteHandlersMu.lock()
-    defer { Database.syncgroupInviteHandlersMu.unlock() }
-    let coreHandler = SyncbaseCore.SyncgroupInvitesScanHandler(onInvite: { invite in
-      handler.onInvite(SyncgroupInvite(
-        syncgroupId: Identifier(coreId: invite.syncgroupId),
-        inviterBlessingNames: invite.blessingNames))
+    let coreHandler = SyncbaseCore.SyncgroupInvitesScanHandler(onInvite: { coreInvite in
+      // We don't automatically pass the invite to the end-user, see
+      // https://github.com/vanadium/issues/issues/1408 for more details.
+      if coreInvite.syncgroupId.name == Syncbase.UserdataSyncgroupName {
+        // Ignore userdata syncgroup which is auto-joined if the blessings are correct in Go.
+        return
+      }
+      let invite = SyncgroupInvite(coreInvite: coreInvite)
+      if invite.syncgroupId.blessing == (try? Principal.userBlessing()) {
+        // Ignore syncgroups with our own blessing -- they are auto-joined by the internal
+        // handler started post-login in Syncbase.swift.
+        return
+      }
+      if (try? Syncbase.syncgroupInUserdata(invite.syncgroupId)) ?? false {
+        // Ignore syncgroups that have already been joined.
+        return
+      }
+
+      handler.onInvite(invite)
     })
+
     do {
       try coreDatabase.scanForSyncgroupInvites(
         try databaseId.encode(),
         handler: coreHandler)
+      Database.syncgroupInviteHandlersMu.lock()
       Database.syncgroupInviteHandlers[handler] = coreHandler
+      Database.syncgroupInviteHandlersMu.unlock()
     } catch {
       handler.onError(error)
     }
@@ -465,14 +485,12 @@
     defer { Database.watchChangeHandlersMu.unlock() }
     if let op = Database.watchChangeHandlers[handler] {
       op.cancel()
+      Database.watchChangeHandlers[handler] = nil
     }
-    Database.watchChangeHandlers[handler] = nil
   }
 
   /// Makes it so all watch change handlers stop receiving notifications attached to this database.
   public func removeAllWatchChangeHandlers() {
-    // Grab a local copy by value so we don't need to worry about concurrency or deadlocking
-    // on the main mutex.
     Database.watchChangeHandlersMu.lock()
     defer { Database.watchChangeHandlersMu.unlock() }
     let handlers = Database.watchChangeHandlers
@@ -482,6 +500,16 @@
     Database.watchChangeHandlers.removeAll()
   }
 
+  func removeAllInternalWatchChangeHandlers() {
+    Database.internalWatchChangeHandlersMu.lock()
+    defer { Database.internalWatchChangeHandlersMu.unlock() }
+    let handlers = Database.internalWatchChangeHandlers
+    for op in handlers.values {
+      op.cancel()
+    }
+    Database.internalWatchChangeHandlers.removeAll()
+  }
+
   public var description: String {
     return "[Syncbase.Database id=\(databaseId)]"
   }
diff --git a/Syncbase/Source/Syncbase.swift b/Syncbase/Source/Syncbase.swift
index 3ebf4a4..1dd325f 100644
--- a/Syncbase/Source/Syncbase.swift
+++ b/Syncbase/Source/Syncbase.swift
@@ -21,6 +21,9 @@
   static var db: Database?
   // The userdata collection, created post-login.
   static var userdataCollection: Collection?
+  // The handler for auto-joining invites that match our user's blessings. We keep it in a var
+  // in order to remove it on `shutdown`.
+  static var autojoinInviteHandler: SyncbaseCore.SyncgroupInvitesScanHandler?
   // Options for opening a database.
   static var cloudName: String?
   static var cloudBlessing: String?
@@ -144,6 +147,15 @@
 
   /// Shuts down the Syncbase service. You must call configure again before any calls will work.
   public static func shutdown() {
+    // We don't need to log/worry about the catch {} -- the exception is if we can't get a database
+    // handle, which would happen if we haven't logged in, etc. The actual remove handler does not
+    // throw any errors.
+    do { try database().removeAllSyncgroupInviteHandlers() } catch { }
+    do { try database().removeAllWatchChangeHandlers() } catch { }
+    do { try database().removeAllInternalWatchChangeHandlers() } catch { }
+    if let handler = autojoinInviteHandler {
+      do { try database().coreDatabase.stopSyncgroupInvitesScan(handler) } catch { }
+    }
     Syncbase.didStartShutdown = true
     SyncbaseCore.Syncbase.shutdown()
     Syncbase.didInit = false
@@ -180,12 +192,39 @@
               NSLog("Syncbase - Error watching userdata syncgroups: %@", "\(err)")
             }
         }))
+      // Auto-join syncgroups with the same blessings.
+      autojoinInviteHandler = SyncbaseCore.SyncgroupInvitesScanHandler(onInvite: onSyncgroupInvite)
+      try coreDb.scanForSyncgroupInvites(
+        try database.databaseId.encode(),
+        handler: autojoinInviteHandler!)
     }
 
     Syncbase.db = database
     Syncbase.didPostLogin = true
   }
 
+  private static func onSyncgroupInvite(invite: SyncbaseCore.SyncgroupInvite) {
+    // Auto-accept groups that have blessings that match our own.
+    let syncgroupId = Identifier(coreId: invite.syncgroupId)
+    guard invite.syncgroupId.blessing == (try? Principal.userBlessing()) &&
+    // Ignore userdata syncgroup which is auto-joined if the blessings are correct in Go.
+    invite.syncgroupId.name != Syncbase.UserdataSyncgroupName &&
+    // Ignore syncgroups that have already been joined.
+    !((try? Syncbase.syncgroupInUserdata(syncgroupId)) ?? false) else {
+      return
+    }
+    do {
+      let invite = SyncgroupInvite(coreInvite: invite)
+      try database().acceptSyncgroupInvite(invite, callback: { (sg, err) in
+        if err != nil {
+          print("Unable to auto-join syncgroup \(sg) with user blessings: \(err)")
+        }
+      })
+    } catch {
+      print("Unable to auto-join syncgroup \(invite.syncgroupId) with user blessings: \(error)")
+    }
+  }
+
   private static func onUserdataWatchChange(changes: [WatchChange]) {
     for change in changes {
       guard let row = change.row where change.entityType == .Row && change.changeType == .Put else {
@@ -218,6 +257,19 @@
     try userdataCollection.put(try Syncbase.UserdataCollectionPrefix + syncgroupId.encode(), value: NSData())
   }
 
+  static func syncgroupInUserdata(syncgroupId: Identifier) throws -> Bool {
+    if !Syncbase.didInit {
+      throw SyncbaseError.NotConfigured
+    }
+    if !Syncbase.didPostLogin {
+      throw SyncbaseError.NotLoggedIn
+    }
+    guard let userdataCollection = Syncbase.userdataCollection else {
+      throw SyncbaseError.IllegalArgument(detail: "No user data collection")
+    }
+    return try userdataCollection.exists(try Syncbase.UserdataCollectionPrefix + syncgroupId.encode())
+  }
+
   /// Returns the shared database handle. Must have already called `configure` and be logged in,
   /// otherwise this will throw a `SyncbaseError.NotConfigured` or `SyncbaseError.NotLoggedIn`
   /// error.
diff --git a/Syncbase/Source/SyncgroupInvite.swift b/Syncbase/Source/SyncgroupInvite.swift
index da353aa..6a5de4e 100644
--- a/Syncbase/Source/SyncgroupInvite.swift
+++ b/Syncbase/Source/SyncgroupInvite.swift
@@ -10,6 +10,12 @@
   public let syncgroupId: Identifier
   public let inviterBlessingNames: [String]
 
+  init(coreInvite: SyncbaseCore.SyncgroupInvite) {
+    self.init(
+      syncgroupId: Identifier(coreId: coreInvite.syncgroupId),
+      inviterBlessingNames: coreInvite.blessingNames)
+  }
+
   public init(syncgroupId: Identifier, inviterBlessingNames: [String]) {
     self.syncgroupId = syncgroupId
     self.inviterBlessingNames = inviterBlessingNames
diff --git a/Syncbase/Tests/BasicDatabaseTests.swift b/Syncbase/Tests/BasicDatabaseTests.swift
index 3ce4e8d..4ef5fd4 100644
--- a/Syncbase/Tests/BasicDatabaseTests.swift
+++ b/Syncbase/Tests/BasicDatabaseTests.swift
@@ -5,6 +5,7 @@
 import XCTest
 @testable import Syncbase
 import enum Syncbase.Syncbase
+import class Syncbase.Database
 @testable import SyncbaseCore
 
 let testQueue = dispatch_queue_create("SyncbaseQueue", DISPATCH_QUEUE_SERIAL)
@@ -138,6 +139,94 @@
   }
 }
 
+class InviteUserdataFilteredTest: SyncgroupTest {
+  func testUserdataFiltered() {
+    withDb { db in
+      db.addSyncgroupInviteHandler(SyncgroupInviteHandler(
+        onInvite: { invite in
+          XCTFail("Not expecting any invites")
+        },
+        onError: { err in
+          XCTFail("Not expecting any errors: \(err)")
+        }))
+      NSThread.sleepForTimeInterval(0.1)
+      db.removeAllSyncgroupInviteHandlers()
+    }
+  }
+}
+
+class InvitedSyncgroupsTest: SyncgroupTest {
+  static let coreSyncgroupId = SyncbaseCore.Identifier(
+    name: "someSg",
+    blessing: "dev.v.io:o:some.apps.googleusercontent.com:someone@google.com")
+  static let coreInvite = SyncbaseCore.SyncgroupInvite(
+    syncgroupId: coreSyncgroupId,
+    addresses: [""],
+    blessingNames: [coreSyncgroupId.blessing])
+
+  func testInvitedSyncgroupsNotIgnoredWhenNew() {
+    withDb { db in
+
+      var didGetInvite = false
+      let handler = SyncgroupInviteHandler(
+        onInvite: { invite in
+          if invite.syncgroupId.name == InvitedSyncgroupsTest.coreSyncgroupId.name {
+            didGetInvite = true
+          }
+        },
+        onError: { err in
+          XCTFail("Not expecting any errors: \(err)")
+      })
+      db.addSyncgroupInviteHandler(handler)
+      let coreHandler = Database.syncgroupInviteHandlers[handler]
+      coreHandler?.onInvite(InvitedSyncgroupsTest.coreInvite)
+      XCTAssertTrue(didGetInvite)
+      db.removeSyncgroupInviteHandler(handler)
+    }
+  }
+
+  func testInvitedSyncgroupsIgnoredWhenJoined() {
+    withDb { db in
+      // We should get the invite when it's not in our userdata
+      let coreInvite = SyncbaseCore.SyncgroupInvite(
+        syncgroupId: InvitedSyncgroupsTest.coreSyncgroupId,
+        addresses: [""],
+        blessingNames: [InvitedSyncgroupsTest.coreSyncgroupId.blessing])
+      var didGetInvite = false
+      var handler = SyncgroupInviteHandler(
+        onInvite: { invite in
+          if invite.syncgroupId == Identifier(coreId: InvitedSyncgroupsTest.coreSyncgroupId) {
+            didGetInvite = true
+          }
+        },
+        onError: { err in
+          XCTFail("Not expecting any errors: \(err)")
+      })
+      db.addSyncgroupInviteHandler(handler)
+      var coreHandler = Database.syncgroupInviteHandlers[handler]
+      coreHandler?.onInvite(coreInvite)
+      XCTAssertTrue(didGetInvite)
+      db.removeSyncgroupInviteHandler(handler)
+
+      // Add it to userdata
+      try Syncbase.addSyncgroupToUserdata(Identifier(coreId: InvitedSyncgroupsTest.coreSyncgroupId))
+
+      // Now we shouldn't get it
+      handler = SyncgroupInviteHandler(
+        onInvite: { invite in
+          XCTFail("Not expecting any invites: \(invite)")
+        },
+        onError: { err in
+          XCTFail("Not expecting any errors: \(err)")
+      })
+      db.addSyncgroupInviteHandler(handler)
+      coreHandler = Database.syncgroupInviteHandlers[handler]
+      coreHandler?.onInvite(coreInvite)
+      db.removeSyncgroupInviteHandler(handler)
+    }
+  }
+}
+
 class WatchSyncgroupTest: SyncgroupTest {
   func testAddingSyncgroup() {
     withDb { db in