Smartlist: Add conversations persistence with Realm

Add conversations persistence to save the history of messages using
Realm and RxRealm libraries

Refactor models to work with Realm :
- Change properties declaration as dynamic vars
- Change Arrays to List
- Change Dictionaries to Objects
- Add default values for non-optionals
- Changed initializers for convenience initializers
- Fixed Tests compilation using @testable import (classes linked twice
exception throwed by Realm)
- Bumped RxSwift version and fixed deprecated methods warning

Change-Id: Ife98e48430740f80ffef9420d857f1ae6e4819d4
parent 5cbb809c
github "ReactiveX/RxSwift"
github "RxSwiftCommunity/RxRealm"
github "RxSwiftCommunity/RxDataSources" == 1.0.3
github "pkluz/PKHUD"
github "ReactiveX/RxSwift" "3.5.0"
github "RxSwiftCommunity/RxDataSources" "1.0.3"
github "RxSwiftCommunity/RxRealm" "0.6.0"
github "pkluz/PKHUD" "4.2.3"
github "realm/realm-cocoa" "v2.8.1"
This diff is collapsed.
......@@ -18,6 +18,8 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import RealmSwift
/**
The different states that an account can have during time.
......@@ -61,19 +63,12 @@ enum AccountType: String {
- expose a clear interface to manipulate the configuration of an account
- keep this configuration
*/
struct AccountConfigModel {
class AccountConfigModel :Object {
/**
The collection of configuration elements.
*/
fileprivate var configValues = Dictionary<ConfigKeyModel, String>()
/**
Default constructor.
*/
init() {
//~ Empty initializer
}
/**
Constructor.
......@@ -81,7 +76,8 @@ struct AccountConfigModel {
- Parameter details: an optional collection of configuration elements
*/
init(withDetails details: Dictionary<String, String>?) {
convenience init(withDetails details: Dictionary<String, String>?) {
self.init()
if details != nil {
for (key, value) in details! {
if let confKey = ConfigKey(rawValue: key) {
......
......@@ -18,6 +18,8 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import RealmSwift
/**
Errors that can be thrown when trying create an AccountCredentialsModel
......@@ -33,10 +35,10 @@ enum CredentialsError: Error {
Its responsability:
- keep the credentials of an account.
*/
struct AccountCredentialsModel {
fileprivate(set) var username: String
fileprivate(set) var password: String
fileprivate(set) var realm: String
class AccountCredentialsModel :Object {
dynamic var username: String = ""
dynamic var password: String = ""
dynamic var accountRealm: String = ""
/**
Constructor.
......@@ -44,33 +46,35 @@ struct AccountCredentialsModel {
- Parameters:
- username: the username of the account
- password: the password of the account
- realm : the realm of the account
- accountRealm : the realm of the account
*/
init(withUsername username: String, password: String, realm: String) {
convenience init(withUsername username: String, password: String, accountRealm: String) {
self.init()
self.username = username
self.password = password
self.realm = realm
self.accountRealm = accountRealm
}
/**
Constructor.
- Parameter raw: raw data to populate the credentials. This collection must contain all the
needed elements (username, password, realm).
needed elements (username, password, accountRealm).
- Throws: CredentialsError
*/
init(withRawaData raw: Dictionary<String, String>) throws {
convenience init(withRawaData raw: Dictionary<String, String>) throws {
self.init()
let username = raw[ConfigKey.AccountUsername.rawValue]
let password = raw[ConfigKey.AccountPassword.rawValue]
let realm = raw[ConfigKey.AccountRealm.rawValue]
let accountRealm = raw[ConfigKey.AccountRealm.rawValue]
if username == nil || password == nil || realm == nil {
if username == nil || password == nil || accountRealm == nil {
throw CredentialsError.NotEnoughData
}
self.username = username!
self.password = password!
self.realm = realm!
self.accountRealm = accountRealm!
}
}
......@@ -19,6 +19,7 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import RealmSwift
import Foundation
/**
......@@ -31,32 +32,31 @@ enum AccountModelError: Error {
/**
A class representing an account.
*/
class AccountModel : Equatable {
class AccountModel : Object {
// MARK: Public members
let id: String
var registeringUsername = false
var devices = Dictionary<String,String>()
var details: AccountConfigModel
var volatileDetails: AccountConfigModel
var credentialDetails = Array<AccountCredentialsModel>()
dynamic var id: String = ""
dynamic var registeringUsername = false
dynamic var details: AccountConfigModel?
dynamic var volatileDetails: AccountConfigModel?
let credentialDetails = List<AccountCredentialsModel>()
let devices = List<DeviceModel>()
// MARK: Init
init(withAccountId accountId: String) {
convenience init(withAccountId accountId: String) {
self.init()
self.id = accountId
self.details = AccountConfigModel()
self.volatileDetails = AccountConfigModel()
}
init(withAccountId accountId: String,
convenience init(withAccountId accountId: String,
details: AccountConfigModel,
volatileDetails: AccountConfigModel,
credentials: [AccountCredentialsModel],
devices: Dictionary<String, String>) throws {
credentials: List<AccountCredentialsModel>,
devices: [DeviceModel]) throws {
self.init()
self.id = accountId
self.details = details
self.volatileDetails = volatileDetails
self.devices = devices
self.devices.append(objectsIn: devices)
}
public static func ==(lhs: AccountModel, rhs: AccountModel) -> Bool {
......
......@@ -40,9 +40,9 @@ struct AccountModelHelper {
*/
func isAccountSip() -> Bool {
let sipString = AccountType.SIP.rawValue
let accountType = self.account.details
let accountType = self.account.details?
.get(withConfigKeyModel: ConfigKeyModel.init(withKey: .AccountType))
return sipString.compare(accountType) == ComparisonResult.orderedSame
return sipString.compare(accountType!) == ComparisonResult.orderedSame
}
/**
......@@ -52,9 +52,9 @@ struct AccountModelHelper {
*/
func isAccountRing() -> Bool {
let ringString = AccountType.Ring.rawValue
let accountType = self.account.details
let accountType = self.account.details?
.get(withConfigKeyModel: ConfigKeyModel.init(withKey: .AccountType))
return ringString.compare(accountType) == ComparisonResult.orderedSame
return ringString.compare(accountType!) == ComparisonResult.orderedSame
}
/**
......@@ -63,7 +63,7 @@ struct AccountModelHelper {
- Returns: true if the account is enabled, false otherwise.
*/
func isEnabled() -> Bool {
return (self.account.details
return (self.account.details!
.getBool(forConfigKeyModel: ConfigKeyModel.init(withKey: .AccountEnable)))
}
......@@ -73,7 +73,7 @@ struct AccountModelHelper {
- Returns: the registration state of the account as a String.
*/
func getRegistrationState() -> String {
return (self.account.volatileDetails
return (self.account.volatileDetails!
.get(withConfigKeyModel: ConfigKeyModel.init(withKey: .AccountRegistrationStatus)))
}
......@@ -124,12 +124,12 @@ struct AccountModelHelper {
var ringId :String? {
let accountUsernameKey = ConfigKeyModel(withKey: ConfigKey.AccountUsername)
let accountUsername = self.account.details.get(withConfigKeyModel: accountUsernameKey)
let accountUsername = self.account.details?.get(withConfigKeyModel: accountUsernameKey)
let ringIdPrefix = "ring:"
if accountUsername.contains(ringIdPrefix) {
let index = accountUsername.range(of: ringIdPrefix)?.upperBound
return accountUsername.substring(from: index!)
if accountUsername!.contains(ringIdPrefix) {
let index = accountUsername?.range(of: ringIdPrefix)?.upperBound
return accountUsername?.substring(from: index!)
} else {
return nil
}
......
......@@ -20,7 +20,7 @@
*/
import UIKit
import CoreData
import RealmSwift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
......@@ -56,73 +56,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
func applicationWillTerminate(_ application: UIApplication) {
self.saveContext()
self.stopDaemon()
}
// MARK: - Core Data stack
lazy var applicationDocumentsDirectory: URL = {
// The directory the application uses to store the Core Data store file. This code uses a directory named "cx.ring.Ring" in the application's documents Application Support directory.
let urls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
return urls[urls.count - 1]
}()
lazy var managedObjectModel: NSManagedObjectModel = {
// The managed object model for the application. This property is not optional. It is a fatal error for the application not to be able to find and load its model.
let modelURL = Bundle.main.url(forResource: "Ring", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: modelURL)!
}()
lazy var persistentStoreCoordinator: NSPersistentStoreCoordinator = {
// The persistent store coordinator for the application. This implementation creates and returns a coordinator, having added the store for the application to it. This property is optional since there are legitimate error conditions that could cause the creation of the store to fail.
// Create the coordinator and store
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: self.managedObjectModel)
let url = self.applicationDocumentsDirectory.appendingPathComponent("SingleViewCoreData.sqlite")
var failureReason = "There was an error creating or loading the application's saved data."
do {
try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: url, options: nil)
} catch {
// Report any error we got.
var dict = [String: AnyObject]()
dict[NSLocalizedDescriptionKey] = "Failed to initialize the application's saved data" as AnyObject?
dict[NSLocalizedFailureReasonErrorKey] = failureReason as AnyObject?
dict[NSUnderlyingErrorKey] = error as NSError
let wrappedError = NSError(domain: "YOUR_ERROR_DOMAIN", code: 9999, userInfo: dict)
// Replace this with code to handle the error appropriately.
// abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
NSLog("Unresolved error \(wrappedError), \(wrappedError.userInfo)")
abort()
}
return coordinator
}()
lazy var managedObjectContext: NSManagedObjectContext = {
// Returns the managed object context for the application (which is already bound to the persistent store coordinator for the application.) This property is optional since there are legitimate error conditions that could cause the creation of the context to fail.
let coordinator = self.persistentStoreCoordinator
var managedObjectContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
managedObjectContext.persistentStoreCoordinator = coordinator
return managedObjectContext
}()
// MARK: - Core Data Saving support
func saveContext () {
if managedObjectContext.hasChanges {
do {
try managedObjectContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nserror = error as NSError
NSLog("Unresolved error \(nserror), \(nserror.userInfo)")
abort()
}
}
}
// MARK: - Ring Daemon
fileprivate func startDaemon() {
......
......@@ -18,14 +18,15 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import UIKit
import RealmSwift
class ContactModel {
class ContactModel :Object {
var ringId: String
var userName: String?
dynamic var ringId: String = ""
dynamic var userName: String?
init(withRingId ringId: String) {
convenience init(withRingId ringId: String) {
self.init()
self.ringId = ringId
}
......
......@@ -19,12 +19,14 @@
*/
import RxSwift
import RealmSwift
class ContactViewModel {
private let nameService = AppDelegate.nameService
private let disposeBag = DisposeBag()
private let contact: ContactModel
private let realm = try! Realm()
let userName = Variable("")
......@@ -46,7 +48,11 @@ class ContactViewModel {
return lookupNameResponse.address != nil && lookupNameResponse.address == self.contact.ringId
}).subscribe(onNext: { [unowned self] lookupNameResponse in
if lookupNameResponse.state == .found {
self.contact.userName = lookupNameResponse.name
try! self.realm.write {
self.contact.userName = lookupNameResponse.name
}
self.userName.value = lookupNameResponse.name
} else {
self.userName.value = lookupNameResponse.address
......
......@@ -18,15 +18,17 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
class ConversationModel {
import RealmSwift
var messages = [MessageModel]()
var recipient: ContactModel
var accountId: String
class ConversationModel :Object {
init(withRecipient recipient: ContactModel, accountId: String) {
let messages = List<MessageModel>()
dynamic var recipient :ContactModel?
dynamic var accountId: String = ""
convenience init(withRecipient recipient: ContactModel, accountId: String) {
self.init()
self.recipient = recipient
self.accountId = accountId
}
}
......@@ -100,11 +100,12 @@ class ConversationViewController: UIViewController, UITextFieldDelegate {
forCellReuseIdentifier: "MessageCellId")
//Bind the TableView to the ViewModel
self.viewModel?.messages.bind(to: tableView.rx.items(cellIdentifier: "MessageCellId", cellType: MessageCell.self))
{ index, messageViewModel, cell in
self.viewModel?.messages
.bind(to: tableView.rx.items(cellIdentifier: "MessageCellId",
cellType: MessageCell.self)) { index, messageViewModel, cell in
cell.messageLabel.text = messageViewModel.content
cell.bubblePosition = messageViewModel.bubblePosition()
}.addDisposableTo(disposeBag)
}.addDisposableTo(disposeBag)
//Scroll to bottom when reloaded
self.tableView.rx.methodInvoked(#selector(UITableView.reloadData)).subscribe(onNext: { element in
......
......@@ -20,10 +20,12 @@
import UIKit
import RxSwift
import RealmSwift
class ConversationViewModel {
let conversation: ConversationModel
let realm = try! Realm()
//Displays the entire date ( for messages received before the current week )
private let dateFormatter = DateFormatter()
......@@ -46,7 +48,7 @@ class ConversationViewModel {
hourFormatter.dateFormat = "HH:mm"
//Create observable from sorted conversations and flatMap them to view models
self.messages = self.conversationsService.conversations.asObservable().map({ conversations in
self.messages = self.conversationsService.conversations.map({ conversations in
return conversations.filter({ currentConversation in
return currentConversation.recipient == conversation.recipient
}).flatMap({ conversation in
......@@ -55,20 +57,20 @@ class ConversationViewModel {
})
})
}).observeOn(MainScheduler.instance)
}
lazy var userName: Variable<String> = {
if let userName = self.conversation.recipient.userName {
if let userName = self.conversation.recipient?.userName {
return Variable(userName)
} else {
let tmp :Variable<String> = ContactHelper.lookupUserName(forRingId: self.conversation.recipient.ringId,
let tmp :Variable<String> = ContactHelper.lookupUserName(forRingId: self.conversation.recipient!.ringId,
nameService: AppDelegate.nameService,
disposeBag: self.disposeBag)
tmp.asObservable().subscribe(onNext: { userNameFound in
self.conversation.recipient.userName = userNameFound
try! self.realm.write {
self.conversation.recipient?.userName = userNameFound
}
}).addDisposableTo(self.disposeBag)
return tmp
......@@ -134,10 +136,10 @@ class ConversationViewModel {
self.conversationsService
.sendMessage(withContent: content,
from: accountService.currentAccount!,
to: self.conversation.recipient)
to: self.conversation.recipient!)
.subscribe(onCompleted: {
let accountHelper = AccountModelHelper(withAccount: self.accountService.currentAccount!)
self.saveMessage(withContent: content, byAuthor: accountHelper.ringId!, toConversationWith: self.conversation.recipient.ringId)
self.saveMessage(withContent: content, byAuthor: accountHelper.ringId!, toConversationWith: (self.conversation.recipient?.ringId)!)
}).addDisposableTo(disposeBag)
}
......
......@@ -20,17 +20,22 @@
import UIKit
import RxSwift
import RealmSwift
class ConversationsService: MessagesAdapterDelegate {
fileprivate let messageAdapter :MessagesAdapter
fileprivate let disposeBag = DisposeBag()
fileprivate let textPlainMIMEType = "text/plain"
fileprivate let realm :Realm = try! Realm()
fileprivate let results :Results<ConversationModel>!
var conversations = Variable([ConversationModel]())
var conversations :Observable<Results<ConversationModel>>
init(withMessageAdapter messageAdapter: MessagesAdapter) {
self.messageAdapter = messageAdapter
self.results = realm.objects(ConversationModel.self)
self.conversations = Observable.collection(from: results)
MessagesAdapter.delegate = self
}
......@@ -54,8 +59,14 @@ class ConversationsService: MessagesAdapterDelegate {
})
}
func addConversation(conversation: ConversationModel) {
self.conversations.value.append(conversation)
func addConversation(conversation: ConversationModel) -> Completable {
return Completable.create(subscribe: { [unowned self] completable in
try! self.realm.write {
self.realm.add(conversation)
}
completable(.completed)
return Disposables.create { }
})
}
func saveMessage(withContent content: String,
......@@ -64,27 +75,26 @@ class ConversationsService: MessagesAdapterDelegate {
currentAccountId: String) -> Completable {
return Completable.create(subscribe: { [unowned self] completable in
let message = MessageModel(withId: nil, receivedDate: Date(), content: content, author: author)
let message = MessageModel(withId: 0, receivedDate: Date(), content: content, author: author)
//Get conversations for this sender
var currentConversation = self.conversations.value.filter({ conversation in
return conversation.recipient.ringId == recipientRingId
var currentConversation = self.results.filter({ conversation in
return conversation.recipient?.ringId == recipientRingId
}).first
//Get the current array of conversations
var currentConversations = self.conversations.value
//Create a new conversation for this sender if not exists
if currentConversation == nil {
currentConversation = ConversationModel(withRecipient: ContactModel(withRingId: recipientRingId), accountId: currentAccountId)
currentConversations.append(currentConversation!)
try! self.realm.write {
self.realm.add(currentConversation!)
}
}
//Add the received message into the conversation
currentConversation?.messages.append(message)
//Upate the value of the Variable
self.conversations.value = currentConversations
try! self.realm.write {
currentConversation?.messages.append(message)
}
completable(.completed)
......@@ -99,23 +109,19 @@ class ConversationsService: MessagesAdapterDelegate {
func setMessagesAsRead(forConversation conversation: ConversationModel) -> Completable {
return Completable.create(subscribe: { completable in
//Get the current array of conversations
let currentConversations = self.conversations.value
return Completable.create(subscribe: { [unowned self] completable in
//Filter unread messages
let unreadMessages = conversation.messages.filter({ messages in
return messages.status != .read
})
for message in unreadMessages {
message.status = .read
try! self.realm.write {
for message in unreadMessages {
message.status = .read
}
}
//Upate the value of the Variable
self.conversations.value = currentConversations
completable(.completed)
return Disposables.create { }
......
/*
* Copyright (C) 2017 Savoir-faire Linux Inc.
*
* Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import RealmSwift
import RxRealm
class DeviceModel: Object {
dynamic var deviceId = ""
convenience init(withDeviceId deviceId: String) {
self.init()
self.deviceId = deviceId
}
}
......@@ -18,20 +18,22 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
class MessageModel {
import RealmSwift
var id: UInt64?
var receivedDate: Date
var content: String
var author: String
var status: MessageStatus
class MessageModel: Object {
init(withId id: UInt64?, receivedDate: Date, content: String, author: String) {
dynamic var id: Int64 = 0
dynamic var receivedDate: Date = Date()
dynamic var content: String = ""
dynamic var author: String = ""
dynamic var status: MessageStatus = .unknown
convenience init(withId id: Int64, receivedDate: Date, content: String, author: String) {
self.init()
self.id = id
self.receivedDate = receivedDate
self.content = content
self.author = author
self.status = .unknown
}
}
/*
* Copyright (C) 2017 Savoir-faire Linux Inc.
*
* Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import UIKit
import RxSwift
class MessagesService: MessagesAdapterDelegate {
fileprivate let messageAdapter :MessagesAdapter
fileprivate let disposeBag = DisposeBag()
fileprivate let textPlainMIMEType = "text/plain"
var conversations = Variable([ConversationModel]())
init(withMessageAdapter messageAdapter: MessagesAdapter) {
self.messageAdapter = messageAdapter
MessagesAdapter.delegate = self
}
func sendMessage(withContent content: String, from senderAccount: AccountModel, to recipient: ContactModel) {
let contentDict = [textPlainMIMEType : content]
self.messageAdapter.sendMessage(withContent: contentDict, withAccountId: senderAccount.id, to: recipient.ringId)
let accountHelper = AccountModelHelper(withAccount: senderAccount)
if accountHelper.ringId! != recipient.ringId {
self.addMessage(withContent: content, byAuthor: accountHelper.ringId!, toConversationWith: recipient.ringId)
}
}
func addConversation(conversation: ConversationModel) {
self.conversations.value.append(conversation)
}
fileprivate func addMessage(withContent content: String, byAuthor author: String, toConversationWith account: String) {
let message = MessageModel(withId: nil, receivedDate: Date(), content: content, author: author)
if author != account {
message.status = .read
}
//Get conversations for this sender
var currentConversation = self.conversations.value.filter({ conversation in
return conversation.recipient.ringId == account
}).first
//Get the current array of conversations
var currentConversations = self.conversations.value
//Create a new conversation for this sender if not exists
if currentConversation == nil {
currentConversation = ConversationModel(withRecipient: ContactModel(withRingId: account))
currentConversations.append(currentC