Collaboration

Collaboration uses a shared branch within a user's treesystem to allow multiple people to access and share the same set of data. Whenever a node in the shared branch is created/modified/deleted, all users with access to the shared branch will be notified of the changes.

A simple example of this can be seen in the ZeroDarkTodo sample app. This is a simple Todo-style app. It allows users to create one or more List's, such as "Groceries", "Weekend Chores", etc. The user can then add Todo items to each List. And the app allows the user to share a List with other users. So, for example, people living together could collaborate on a Grocery list.

To see how this works, imagine that Alice has a List that she'd like to share with Bob. First, let's take a look at Alice's treesystem:

            (Alice)
            /     \
   (Homework)     (Groceries)
     /  |  \       /  |   |  \
   (A) (B) (C)   (D) (E) (F) (G)

Alice has 2 Lists: "Homework" & "Groceries". Now she decides to share the Groceries list with Bob, so they can collaborate. The idea is to graft Alice's "Groceries" branch into Bob's treesystem:

            (Alice)                          (Bob)
            /     \                          /   \
   (Homework)     (Groceries)<-----------(ptr)     (Weekend Chores)
     /  |  \       /  |   |  \                         /  |  \
   (A) (B) (C)   (D) (E) (F) (G)                     (A) (B) (C)

Once grafted, the "Groceries" list will act like a local list within Bob's treesystem. But the list actually resides within Alice's treesystem. This allows Alice & Bob to share the same list – changes made by either party will be visible to both.

And this collaboration can extend to multiple users. For example, Alice could also invite Carol, who would similarly graft the node into her treesystem.

 


Sender's Code

To accomplish this, Alice performs 2 tasks to setup the collaboration.

First, she gives Bob read-write permission to the Groceries List. By granting Bob read permission, he will be able to see all the Todo items in the list (D, E, F, G). And by granting Bob write permission, he will be able to create, modify & delete items within the Groceries list.

Second, she sends an invitation message to Bob.

zdc.databaseManager?.rwDatabaseConnection.asyncReadWrite {(transaction) in

  if let cloudTransaction = zdc.cloudTransaction(transaction, forLocalUserID: localUserID) {

    // Step 1: Update treesystem permissions
    let shareItem = ZDCShareItem()
    shareItem.addPermission(ZDCSharePermission.read)
    shareItem.addPermission(ZDCSharePermission.write)

    let permsOps =
        cloudTransaction.recursiveAddShareItem(shareItem, forUserID: bobsUserID, nodeID: listNodeID)

    // Step 2: Send invitation message to Bob
    if let bob = cloudTransaction.user(id: bobsUserID) {
      do {
        let message = try cloudTransaction.sendMessage(toRecipients: [bob], withDependencies: permsOps)

        cloudTransaction.setTag(listNodeID, forNodeID: message.uuid, withIdentifier: "listNodeID")
      } catch {}
      // Invitation message is now queued
    }
  }
}

Notes:

  • The recursiveAddShareItem gives Bob read-write permission for the "Groceries" list, and all of its children, grand-children, etc. So in this example that includes (D, E, F, G).
  • The permsOps variable is an array of ZDCCloudOperation items, which represent the queued changes that need to be pushed to the server. (i.e. the changes that update the permissions)
  • When we create the message, we specify that the message depends on these other operations.
  • This means the framework won't send the message until after its completed executing the permission changes.
  • In other words, Bob won't receive the invitation until the branch has been updated to allow him access. Which is exactly what we want here.

The format of the invitation message itself is completely up to you.

/// ZeroDarkCloudDelegate protocol function.
/// 
/// When the framework is ready to send the message,
/// it will invoke this function to obtain the data we want to send.
/// We can return whatever we need,
/// and the framework handles the encryption & uploading.
///
func data(forMessage message: ZDCNode, transaction: YapDatabaseReadTransaction) -> ZDCData? {

  if let cloudTransaction = zdc.cloudTransaction(transaction, forLocalUserID: message.localUserID),
     let listNodeID = cloudTransaction.tag(forNodeID: message.uuid, withIdentifier: "listNodeID") as? String,
     let listNode = cloudTransaction.node(id: listNodeID),
     let list = cloudTransaction.linkedObject(forNodeID: listNodeID) as? List,
     let graftInvite = cloudTransaction.graftInvite(for: listNode)
  {   
    let invitation = [
      "title"     : list.title, // <= "Groceries",
      "message"   : "Hey Bob, let's tackle this holiday dinner",
      "cloudID"   : graftInvite.cloudID,
      "cloudPath" : graftInvite.cloudPath.path()
    ]

    do {
      let encoder = JSONEncoder()
        let jsonData = try encoder.encode(invitation)
      return ZDCData(data: jsonData)
    } catch {} 
  }

  return nil
}

The graftInvite contains the 2 required items:

  • cloudPath — The location of the node within the cloud.  (The cloudPath is an encrypted version of the treesystem path. The encryption ensures the server cannot read the names of nodes. This is part of the zero-knowledge architecture of ZeroDark.cloud.)
  • cloudID — A unique uuid for the node.  (This ensures the invitee can locate the node, even if the inviter renames or moves the node. The server can assist in this task, if the invitee knows the cloudID.)

The framework also informs us of when the message has been sent:

/// ZeroDarkCloudDelegate protocol function.
///
func didSendMessage(_ message: ZDCNode, toRecipient recipient: ZDCUser, transaction: YapDatabaseReadWriteTransaction) {

  // Our message has been sent
}

 


Receiver's Code

On Bob's side, he receives the message in the same way he discovers any new node:

/// ZeroDarkCloudDelegate protocol function.
/// Framework just discovered a new node in the cloud.
/// 
func didDiscoverNewNode(_ node: ZDCNode, at path: ZDCTreesystemPath, transaction: YapDatabaseReadWriteTransaction) {

   guard let cloudTransaction = zdc.cloudTransaction(transaction, forLocalUserID: node.localUserID) else {
      return
   }

   switch path.trunk {
      case .home {
         // Discovered normal node within our treesystem
         break
      }
      case .outbox {
         // Discovered outgoing message (sent from another device)
         break
      }
      case .inbox {
         // Discovered incoming message (sent from another user)
         cloudTransaction.markNodeAsNeedsDownload(node.uuid, components: .all)
         // We can download it now.
         downloadNode(node, at: path)
      }
   }
}

Downloading nodes is easy with the help of the DownloadManager:

private func downloadNode(_ node: ZDCNode, at path: ZDCTreesystemPath) {

   let options = ZDCDownloadOptions()
   options.cacheToDiskManager = false
   options.canDownloadWhileInBackground = true
   options.completionTag = String(describing: type(of: self))

   let queue = DispatchQueue.global()

   zdc.downloadManager!.downloadNodeData( node,
                                 options: options,
                         completionQueue: queue)
   { (cloudDataInfo: ZDCCloudDataInfo?, cryptoFile: ZDCCryptoFile?, error: Error?) in

      if let cloudDataInfo = cloudDataInfo,
         let cryptoFile = cryptoFile
      {
         // The downloaded file is still encrypted.
         // That is, the file is stored in the cloud in an encrypted fashion.
         //
         // (Remember, ZeroDark.cloud is a zero-knowledge sync & messaging system.
         //  This means the ZeroDark servers cannot read any of our content.)
         //
         // So we need to decrypt the file.
         // Since this is a small file, we can just decrypt it into memory.
         //
         // Note: We're already executing in a background thread (DispatchQueue.global).
         //       So it's fine if we read from the disk in a synchronous fashion here.

         let jsonData = try ZDCFileConversion.decryptCryptoFile(intoMemory: cryptoFile)

         // Process it
         handleInvite(jsonData)

         // Cleanup: delete the downloaded file,
         // unless we instructed the DiskManager to manage it.
         zdc.diskManager?.deleteFileIfUnmanaged(cryptoFile.fileURL)
      }
   }
}

Handling an "invite" message is app-specific. We can imagine various user-interface options for such a thing. But if Bob eventually accepts the invitation, then we can perform the graft:

zdc.databaseManager?.rwDatabaseConnection.asyncReadWrite {(transaction) in

   let localUserID = invitation.receiverID
   let senderUserID = invitation.senderID

   guard
      let cloudTransaction = zdc.cloudTransaction(transaction, forLocalUserID: localUserID),
      let sender = cloudTransaction.user(id: senderUserID)
   else {
      return
   }

   // We're going to "graft" the remote user's List into our treesystem.
   // Here's a visualization:
   //
   // (localUser)     (remoteUser)
   //      |                |
   //    (home)          (home)
   //     /  \            /  \
   //   (A)  (B)=======>(C)  (D)
   //                   /|\
   //                  / | \
   //                (1)(2)(3)
   //
   // So we're grafting List (C) into our own treesystem.
   // And this will allow us to see Todo items (1), (2) & (3).

   let localPath = ZDCTreesystemPath(pathComponents: [invitation.title])
   // ^ home://Groceries

   do {
      try cloudTransaction.graftNode(withLocalPath: localPath,
                                   remoteCloudPath: remoteCloudPath,
                                     remoteCloudID: invitation.cloudID,
                                        remoteUser: sender)
   } catch {}
}

And that's all there is to it. Once the graft is successful, the framework will start discovering the nodes in Alice's treesystem, and Bob's device will be notified via the standard didDiscoverNewNode protocol function.