Building a Peer-to-Peer Photo Sharing App with Couchbase Mobile
A couple of months ago, Traun Layden and I approached Pasin Suriyentrakorn with an idea to create a cloudless P2P app using Couchbase Mobile. This article follows that process in his own words:
Pasin, take it away...
Thanks Wayne. One of the cool features of Couchbase Lite that hasn’t been featured much is an ability to do P2P replication between two devices. Couchbase Lite is packaged with an extra component called Couchbase Lite Listener that allows your application to accept HTTP connections from other devices running Couchbase Lite and sync data with them.
So, Wayne Carter (Chief Architect of Mobile at Couchbase) and Traun Leyden (Sr. Software Engineer, Couchbase Lite for Android) skyped me and sold me on the idea about creating a simple P2P photo sharing app that can demonstrate how easily you can develop a P2P application using Couchbase Lite. I was engaged since then and spent a couple days working on this little P2P iOS photo sharing app called PhotoDrop. In this blog post, I will show you how I used Couchbase Lite to develop the application. This is a video of what you'll end up with at the end: http://youtu.be/glsujG99hMc
Overview
PhotoDrop is a P2P photo sharing app similar to the iOS AirDrop feature that you can use to send photos across devices. Before jumping into the code, I would like to briefly discuss P2P concerns and design choices that I made when developing the application.
Peer Discovery can be done in several ways. In iOS, you can use the Bonjour Service for discovering peers but this could be an issue if you later want to develop the application in other platforms. In PhotoDrop, I am using a simpler and more direct way using a QRCode. I use the QRCode to advertise an adhoc endpoint URL that a sender can scan and send photos to.
Peer Identity is a related subject to the Peer Discovery. Peer Identiy is normally difficult to solve without introducing some undesired steps into the application such as user registration, user login, and peer approval. By using a QRCode, and requiring the two people to have direct explicit interaction to send and receive photos, the issue is mitigated.
Authentication is needed to ensure that the access control, for this case the write access to push photos into another peer's database, is granted to the right person. In PhotoDrop, I am using the Basic Auth that Couchbase Lite has already supported. I securely generate a one-time username and password, bundle them with the URL endpoint and encode them all in the QRCode presented by the receiver to the sender. Once the sender scans the QRCode, the sender will have the username and password for basic authentication.
Secure Communication Channels are requried especially for sending sensitive information. I did not implement secure communication in this app. However, recently Jen Alfke added TLS support including an API to generate a self-signed certificate on the fly to the iOS Couchbase Lite Listener. As all the hard work has been done, You can add support for this pretty easily.
After all, the current flow of the PhotoDrop is fairly simple. You select photos you want to share to your friend and open the QRCode scanner to scan the target endpoint that the selected photos will be sent to. On the other side, your friend opens the appliation, shows the QRCode and waits for you to scan and send the photos. The next section will provide all of the implementation details for the application.
Selecting Photos
PhotoDrop uses ALAssetsLibrary to access to the photos in the Camera Roll album on an iOS device because the UIImagePickerViewController that doesn't support multiple photo selection.
// ViewController.swift // MARK: - ALAssetsLibrary func reloadAssets() { if library == nil { library = ALAssetsLibrary() } assets.removeAll(keepCapacity: true) library.enumerateGroupsWithTypes(ALAssetsGroupSavedPhotos, usingBlock: { (group:ALAssetsGroup!, stop:UnsafeMutablePointer<ObjCBool>) -> Void in if group != nil { group.setAssetsFilter(ALAssetsFilter.allPhotos()) group.enumerateAssetsWithOptions(NSEnumerationOptions.Reverse, usingBlock: { (asset:ALAsset!, index:Int, stop: UnsafeMutablePointer<ObjCBool>) -> Void in if asset != nil { self.assets.append(asset) } }) } else { dispatch_async(dispatch_get_main_queue(), { self.collectionView.reloadData() }) } }) { (error:NSError!) -> Void in } }
The photos are displayed in a UICollectionViewController. A simple custom UICollectionViewCell named PhotoViewCell is created to display a photo thumbnail and a checkbox indicating the selected status of the photo.
// ViewController.swift // MARK: - UICollectionView func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCellWithReuseIdentifier("photoCell", forIndexPath: indexPath) as PhotoViewCell let asset = assets[indexPath.row] cell.imageView.image = UIImage(CGImage: asset.thumbnail().takeUnretainedValue()) cell.checked = contains(selectedAssets, asset) return cell } func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) { let cell = collectionView.cellForItemAtIndexPath(indexPath) as PhotoViewCell let asset = assets[indexPath.row] if let foundIndex = find(selectedAssets, asset) { selectedAssets.removeAtIndex(foundIndex) cell.checked = false } else { selectedAssets.append(asset) cell.checked = true } collectionView.reloadItemsAtIndexPaths([indexPath]) self.enableSendMode(selectedAssets.count > 0) }
Sending Photos
When the selected photos are ready to send and the send button is touched, the SendViewController will be presented with the selected photos.
Once the SendViewController is presented, we find ourselves in the viewDidLoad() function in which we get an empty database object named "db" from the DatabaseUtil class. The reason that we get a fresh database is to ensure that there are no pending documents from a previous sharing session. The getEmptyDatabase() function of the DatabaseUtil class returns an empty database with the given name by deleting the database if it does exist and recreating a new one.
// DatabaseUtil.swift class func getEmptyDatabase(name: String!, error: NSErrorPointer) -> CBLDatabase? { if let database = CBLManager.sharedInstance().existingDatabaseNamed(name, error: nil) { if !database.deleteDatabase(error) { return nil; } } return CBLManager.sharedInstance().databaseNamed(name, error: error) }
Once the SendViewController is presented, we will be in the viewDidAppear(animated:Bool) function. In the viewDidAppear function, we start the AVFoundation's QRCode capturing session to present the QRCode scanner.
// SendViewController.swift override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) if database != nil && session == nil { startCaptureSession() } } func startCaptureSession() { let app = UIApplication.sharedApplication().delegate as AppDelegate let device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) if device == nil { AppDelegate.showMessage("No video capture devices found", title: "") return } var error: NSError? let input = AVCaptureDeviceInput.deviceInputWithDevice(device, error: &error) as AVCaptureDeviceInput if error == nil { session = AVCaptureSession() session.addInput(input) let output = AVCaptureMetadataOutput() output.setMetadataObjectsDelegate(self, queue: dispatch_get_main_queue()) session.addOutput(output) output.metadataObjectTypes = [AVMetadataObjectTypeQRCode] previewLayer = AVCaptureVideoPreviewLayer.layerWithSession(session) as AVCaptureVideoPreviewLayer previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill previewLayer.frame = self.previewView.bounds self.previewView.layer.addSublayer(previewLayer) session.startRunning() } else { AppDelegate.showMessage("Cannot start QRCode capture session", title: "Error") } }
When a QRCode is captured, we extract the endpoint URL which is the remote URL that we will send photos to. The core code for creating and sending photo documents begins and ends in the replicate(url: NSURL) function which has about 50 lines of code.
The code starts with looping thru each photo in the sharedAssets array. For each photo, we get a binary representation and attach to an empty Couchbase Lite document.
// SendViewController.swift func replicate(url: NSURL) { self.previewView.hidden = true; self.statusLabel.text = "Sending Photos ..." UIApplication.sharedApplication().networkActivityIndicatorVisible = true var docIds: [String] = [] for asset in sharedAssets! { let representation = asset.defaultRepresentation() var bufferSize = UInt(Int(representation.size())) var buffer = UnsafeMutablePointer<UInt8>(malloc(bufferSize)) var buffered = representation.getBytes(buffer, fromOffset: 0, length: Int(representation.size()), error: nil) var data = NSData(bytesNoCopy: buffer, length: buffered, freeWhenDone: true) var error: NSError? let doc = database.createDocument() let rev = doc.newRevision() rev.setAttachmentNamed("photo", withContentType: "application/octet-stream", content: data) let saved = rev.save(&error) if saved != nil { docIds.append(doc.documentID) } } ... }
After we finish creating the photo documents, we send those documents to the other device. To do this, We create a push replicator and set the ids of the documents that we would like to replicate. In general, setting the document ids for the replicator is optional. If ids is not supplied, the the replicator would just replicate all of the documents in the database.
Before we start the replicator, we setup a notification observer to observe the replicating status so that we can display replication status appropriately when the replication in in progress.
// SendViewController.swift func replicate(url: NSURL) { ... if docIds.count > 0 { replicator = database.createPushReplication(url) replicator.documentIDs = docIds NSNotificationCenter.defaultCenter().addObserverForName(kCBLReplicationChangeNotification, object: replicator, queue: nil) { (notification) -> Void in if self.replicator.lastError == nil { var totalCount = self.replicator.changesCount var completedCount = self.replicator.completedChangesCount if completedCount > 0 && completedCount == totalCount { self.statusLabel.text = "Sending Completed" UIApplication.sharedApplication().networkActivityIndicatorVisible = false } } else { self.statusLabel.text = "Sending Abort" UIApplication.sharedApplication().networkActivityIndicatorVisible = false } } replicator.start() } }
Receiving Photos
Receiving photos is done in ReceiveViewController which is presented from the ViewController screen when a user touches the Receive button. When the ReceiveViewController is presented, we get a fresh database named "db" in the viewDidLoad() method. In the viewDidAppear(animated: Bool) function, we call startListener() to create and start a CBLListener object. The CBLListener is an embedded lightweight HTTP server which listens to HTTP requests and routes those requests to appropriate handlers to perform replication operations. Once the listener is started, we can obtain the listener's URL and present a QRCode encoding the URL. As a bonus, iOS CoreImage supports a QRCode filter so generating a QRCode image is really easy :)
// ReceiveViewController.swift override func viewDidLoad() { super.viewDidLoad() collectionView.hidden = true; var error: NSError? database = DatabaseUtil.getEmptyDatabase("db", error: &error) if error != nil { AppDelegate.showMessage("Cannot get a database with error : \(error!.code)", title: "Error") } } override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) if database == nil { return; } if (!startListener()) { AppDelegate.showMessage("Cannot start listener", title: "Error") return; } if syncUrl != nil { imageView.image = UIImage.qrCodeImageForString(syncUrl.absoluteString, size: imageView.frame.size) } } // MARK: - Listener func startListener() -> Bool { if listener != nil { return true } var error: NSError? listener = CBLListener(manager: CBLManager.sharedInstance(), port: 0) // Enable Basic Authentication listener.requiresAuth = true let username = secureGenerateKey(NSCharacterSet.URLUserAllowedCharacterSet()) let password = secureGenerateKey(NSCharacterSet.URLPasswordAllowedCharacterSet()) listener.setPasswords([username : password]) var success = listener.start(&error) if success { // Set a sync url with the generated username and password: if let url = NSURL(string: database.name, relativeToURL: listener.URL) { if let urlComp = NSURLComponents(string: url.absoluteString!) { urlComp.user = username urlComp.password = password syncUrl = urlComp.URL } } // Start observing for database changes: startObserveDatabaseChange() return true } else { listener = nil return false } }
When we setup the listener in startListener(), we enable Basic Authentication with the generated username/password pair. The generated username/password pair serves as a one-time pair that provides a secure (enough) solution to prevent unauthorized users from pushing images to the receiver. To generate the username and password, we use the iOS Randomization Services API (SecRandomCopyBytes) that generates cryptographically secure random values.
func secureGenerateKey(allowedCharacters: NSCharacterSet) -> String { let data = NSMutableData(length:32)! SecRandomCopyBytes(kSecRandomDefault, 32, UnsafeMutablePointer<UInt8>(data.mutableBytes)) let key = data.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.Encoding64CharacterLineLength) return key.stringByAddingPercentEncodingWithAllowedCharacters(allowedCharacters)! }
At the end of the startListener() function, the startObserveDatabaseChange() function is called to observe any changes happening to the database via notifications named kCBLDatabaseChangeNotification. We are now able to observe when the photo documents from the other device are replicated and save the photos into the device's camera roll.
// ReceiveViewController.swift // MARK: - Database Change func startObserveDatabaseChange() { NSNotificationCenter.defaultCenter().addObserverForName(kCBLDatabaseChangeNotification, object: database, queue: nil) { (notification) -> Void in if let changes = notification.userInfo!["changes"] as? [CBLDatabaseChange] { for change in changes { dispatch_async(dispatch_get_main_queue(), { if self.collectionView.hidden { self.collectionView.hidden = false } self.saveImageFromDocument(change.documentID) }) } } } }
The function to save a photo into to the device's camera roll is below. The function simply gets the image from the document attachment, creates a CGImage and saves it to the camera roll via the ALAssetsLibrary's writeImageDataToSavedPhotosAlbum() function. After saving a photo, we display a thumbnail on the screen.
// ReceiveViewController.swift func saveImageFromDocument(docId: String) { let app = UIApplication.sharedApplication().delegate as AppDelegate if let doc = app.database.existingDocumentWithID(docId) { if doc.currentRevision.attachments.count > 0 { let attachment = doc.currentRevision.attachments[0] as CBLAttachment if let image = UIImage(data: attachment.content)?.CGImage { let library = assetsLibrary() library.writeImageDataToSavedPhotosAlbum(attachment.content, metadata: nil, completionBlock: { (url: NSURL!, error: NSError!) -> Void in if url != nil { library.assetForURL(url, resultBlock: {(asset: ALAsset!) -> Void in self.assets.insert(asset, atIndex: 0) dispatch_async(dispatch_get_main_queue(), { self.collectionView.insertItemsAtIndexPaths( [NSIndexPath(forRow: 0, inSection: 0)]) }) }) {(error: NSError!) -> Void in } } }) } } } }
Wrapping up
There are many ways to develop the PhotoDrop app, and using Couchbase Lite is perhaps one of the easiest. The core code for sending and receiving photos is barely 100 lines of code and contains zero lines of code directly involved in network communication. I hope this blog post and the PhotoDrop application itself gives you some inspiration and ideas for using Couchbase Lite for P2P applications. Clone the PhotoDrop GitHub repo, play with it, and let me know what you think!
Comments