Статьи

Создание однорангового приложения для обмена фотографиями с Couchbase Mobile

Пару месяцев назад мы с Трауном Лейденом обратились к Pasin Suriyentrakorn с идеей создать безоблачное P2P-приложение с использованием Couchbase Mobile. Эта статья следует этому процессу своими словами:

Пасин, убери это …

Спасибо, Уэйн. Одна из замечательных особенностей Couchbase Lite, которая не была так популярна, — это возможность выполнять P2P-репликацию между двумя устройствами. Couchbase Lite поставляется с дополнительным компонентом, называемым Couchbase Lite Listener, который позволяет вашему приложению принимать HTTP-соединения от других устройств, работающих под управлением Couchbase Lite, и синхронизировать данные с ними.

С тех пор я был помолвлен и провел пару дней, работая над этим небольшим P2P-приложением для обмена фотографиями для iOS под названием PhotoDrop. В этом посте я покажу вам, как я использовал Couchbase Lite для разработки приложения. Это видео о том, чем вы закончите в конце:   http://youtu.be/glsujG99hMc 

обзор

PhotoDrop — это приложение для обмена фотографиями P2P, аналогичное функции iOS AirDrop, которую можно использовать для отправки фотографий на устройства. Прежде чем углубляться в код, я хотел бы кратко обсудить проблемы P2P и варианты дизайна, которые я сделал при разработке приложения.

Peer Discovery можно сделать несколькими способами. В iOS вы можете использовать Bonjour Service для обнаружения пиров, но это может быть проблемой, если позже вы захотите разработать приложение на других платформах. В PhotoDrop я использую более простой и прямой способ использования QRCode. Я использую QRCode для рекламы URL-адреса конечной точки adhoc, который отправитель может сканировать и отправлять фотографии.

Идентичность сверстников — это связанный с Discovery Discovery предмет. Идентификацию одноранговой сети, как правило, трудно решить без введения в приложение некоторых нежелательных шагов, таких как регистрация пользователя, вход в систему пользователя и утверждение со стороны партнера. Используя QRCode и требуя, чтобы два человека имели прямое явное взаимодействие для отправки и получения фотографий, эта проблема устраняется.

Аутентификация необходима для того, чтобы гарантировать, что управление доступом, в данном случае доступ на запись для отправки фотографий в базу данных другого партнера, будет предоставлено нужному человеку. В PhotoDrop я использую базовую аутентификацию, которую Couchbase Lite уже поддерживает. Я надежно генерирую одноразовые имя пользователя и пароль, связываю их с конечной точкой URL и кодирую их все в QRCode, предоставляемом получателем отправителю. После того, как отправитель отсканирует QRCode, отправитель получит имя пользователя и пароль для базовой аутентификации.

Безопасные каналы связи требуются специально для отправки конфиденциальной информации. Я не реализовал безопасную связь в этом приложении. Однако недавно Джен Алфке добавила поддержку TLS, в том числе API, для создания самозаверяющего сертификата на лету в iOS Couchbase Lite Listener. Поскольку вся тяжелая работа была проделана, вы можете добавить поддержку для этого довольно легко.

В конце концов, текущий поток PhotoDrop довольно прост. Вы выбираете фотографии, которыми хотите поделиться с другом, и открываете сканер QRCode, чтобы отсканировать конечную точку, на которую будут отправлены выбранные фотографии. С другой стороны, ваш друг открывает приложение, показывает QRCode и ждет, пока вы отсканируете и отправите фотографии. В следующем разделе будут представлены все детали реализации приложения.

Выбор фотографий

PhotoDrop использует ALAssetsLibrary для доступа к фотографиям в альбоме Camera Roll на устройстве iOS, потому что UIImagePickerViewController, который не поддерживает выбор нескольких фотографий.

// 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
    }
}

Фотографии отображаются в UICollectionViewController. Простой пользовательский UICollectionViewCell с именем PhotoViewCell создается для отображения миниатюры фотографии и флажка, указывающего выбранный статус фотографии.

// 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)
}

Отправка фотографий

Когда выбранные фотографии готовы к отправке и нажата кнопка отправки, SendViewController будет представлен с выбранными фотографиями.

Как только SendViewController представлен, мы оказываемся в функции viewDidLoad (), в которой мы получаем пустой объект базы данных с именем «db» из класса DatabaseUtil. Причина, по которой мы получаем новую базу данных, состоит в том, чтобы гарантировать отсутствие ожидающих документов из предыдущего сеанса обмена. Функция getEmptyDatabase () класса DatabaseUtil возвращает пустую базу данных с заданным именем, удаляя базу данных, если она существует, и заново создавая новую.

// 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)
}

Как только SendViewController будет представлен, мы будем в функции viewDidAppear (animated: Bool). В функции viewDidAppear мы запускаем сеанс захвата QRCode AVFoundation, чтобы представить сканер QRCode.

// 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")
    }
}

Когда захватывается QRCode, мы извлекаем URL конечной точки, который является удаленным URL, на который мы будем отправлять фотографии. Основной код для создания и отправки фотодокументов начинается и заканчивается в функции replicate (url: NSURL), которая содержит около 50 строк кода.

Код начинается с зацикливания каждой фотографии в массиве sharedAssets. Для каждой фотографии мы получаем двоичное представление и прикрепляем к пустому документу Couchbase Lite.

// 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)
        }
    }
    ...
}

После того как мы завершим создание фотодокументов, мы отправляем эти документы на другое устройство. Для этого мы создаем push-репликатор и устанавливаем идентификаторы документов, которые мы хотели бы тиражировать. В общем, установка идентификаторов документов для репликатора не является обязательной. Если идентификаторы не указаны, репликатор просто скопирует все документы в базе данных.

Перед запуском репликатора мы настраиваем наблюдателя уведомлений для наблюдения за состоянием репликации, чтобы мы могли надлежащим образом отображать состояние репликации, когда репликация выполняется.

// 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()
    }
}

Получение фотографий

Прием фотографий осуществляется в ReceiveViewController, который отображается на экране ViewController, когда пользователь нажимает кнопку «Получить». Когда ReceiveViewController представлен, мы получаем новую базу данных с именем «db» в методе viewDidLoad (). В функции viewDidAppear (animated: Bool) мы вызываем startListener () для создания и запуска объекта CBLListener. CBLListener — это встроенный облегченный HTTP-сервер, который прослушивает HTTP-запросы и направляет эти запросы соответствующим обработчикам для выполнения операций репликации. Как только слушатель запущен, мы можем получить URL слушателя и представить QRCode, кодирующий URL. В качестве бонуса, iOS CoreImage поддерживает фильтр QRCode, поэтому генерация изображения QRCode действительно проста 🙂

// 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
    }
}

Когда мы настраиваем прослушиватель в startListener (), мы включаем базовую аутентификацию с созданной парой имя пользователя / пароль. Сгенерированная пара имя пользователя / пароль служит одноразовой парой, которая обеспечивает безопасное (достаточное) решение для предотвращения отправки неавторизованными пользователями изображений в приемник. Для генерации имени пользователя и пароля мы используем API сервисов рандомизации iOS (SecRandomCopyBytes), который генерирует криптографически безопасные случайные значения.

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)!
}

В конце функции startListener () вызывается функция startObserveDatabaseChange () для наблюдения за любыми изменениями, происходящими в базе данных, с помощью уведомлений с именем kCBLDatabaseChangeNotification. Теперь мы можем наблюдать, когда фотодокументы с другого устройства копируются, и сохранять фотографии в фотоаппарате устройства.

// 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)
                    })
                }
            }
    }
}

Функция сохранения фотографии в рулон камеры устройства находится ниже. Функция просто получает изображение из вложения документа, создает CGImage и сохраняет его в кадре камеры через функцию writeImageDataToSavedPhotosAlbum () ALAssetsLibrary. После сохранения фотографии мы показываем миниатюру на экране.

// 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
                        }
                    }
                })
            }
        }
    }
}

Завершение

Существует множество способов разработки приложения PhotoDrop, и использование Couchbase Lite, пожалуй, один из самых простых. Основной код для отправки и получения фотографий составляет всего 100 строк кода и содержит ноль строк кода, непосредственно участвующих в сетевом взаимодействии. Я надеюсь, что этот пост и само приложение PhotoDrop дадут вам вдохновение и идеи для использования Couchbase Lite для приложений P2P. Клон репо PhotoDrop GitHub , играть с ним, и дайте мне знать , что вы думаете!