Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Build a local cache for offline messages

  • Elena Jovchevska
July 15th, 2019
You will need to have Xcode 10+ and CocoaPods installed on your machine.

The Chatkit SDK by Pusher is a powerful tool which allows us as developers to easily build multiple chat features and enrich our application. When building a feature it is important to solve and propose implementation of its edge cases.

The most common problem for the chat feature must be the handling of no internet connection state. The intuitive solution is building a local cache system, which will allow users to preview the failed messages, with an option to resend them.

Follow this step by step tutorial for effortless integration of local cache and use of the iOS Chatkit SDK.

Here is a demo of what we will build:

Prerequisites

For building and running the iOS Application you will need Xcode installed (10.2 version is used). You can install it from the link above. If you need to install CocoaPods, run the following command in your console:

    gem install cocoapods

Chatkit signup

The first step towards using the Chatkit SDK in your application is to sign up to the following [link] (https://dash.pusher.com/), for using the Chatkit services. These will be later on used in your application.

Under CHATKIT, select the option to create instance.

Now that the instance is created you will get an overview of the instance locator and test token provider under Credentials tab. You will need these for connecting with the SDK below in the code.

The second step is performed in the Console tab. Here you will be presented with the option to create user.

The first users that were created for the purpose of this tutorial are Agent1 and Customer1.

The console tab extends with more options, such as creating a room for the existing users. Make sure to create a room for the above users, name it Support. While creating the room, you can add one user in it. When the room is created, select the option Add user to room and add the second user in it.

When finished, the console is updated with the rooms overview, such as messages sent in the room, members and options to add new users or to delete the room.

Project setup

After the initial setup is done, the next step is cloning the project from the following link. There are two ways to start working with Chatkit and use the SDK in our application. The first one is through CocoaPods, which is a dependency manager for cocoa projects, while the second one is through Carthage, which is a decentralized dependency manager. For more details you can checkout the following link. In this project CocoaPods is used.

The current Podfile, which can be found in the project folder:

    source 'https://github.com/CocoaPods/Specs.git'
    platform :ios, '11.0'
    use_frameworks!

    target 'MessagesCache' do
      pod 'PusherChatkit'
    end

Whenever you create a new iOS project and you want to integrate pods it is necessary to run the following command:

    pod install

This will fetch and install the pods specified in the podfile and create your .xcworkspace which integrates the pods and the main project. Use this for further development. Now open the workspace in Xcode.

Local cache

The caching of the temporary data on a single device was done with Core Data framework. It offers easy mapping of the objects, without the need to administer a database directly.

The project you downloaded from GitHub, is the final version of this tutorial. Follow the step by step guidance so you can understand the concept behind the local cache.

The starting point from the user perspective is IntroViewController.swift . Here the user is prompted with simple interface to enter their ID and to proceed towards the chat feature. In a production project we would have login API, providing the backend with username and password credentials, which will authenticate the user and enables/disables our login. But for the purpose of this tutorial the user ID that we enter on the login screen is the same as the one created in the Chatkit Console.

The allowed user ID’s are previously saved in UserDefaults. Accessible through the StorageAccess.

    // IntroViewController.swift

    import Foundation
    import UIKit

    class IntroViewController: AbstractViewController {
        @IBOutlet weak var infoTextfield: UITextField!
        let storageAccess = StorageAccess()

        //MARK: Lifecycle
        override func viewDidLoad() {
            super.viewDidLoad()
            infoTextfield.becomeFirstResponder()
        }

        override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
            if segue.identifier == "chatIdentifier" {
                let destinationVC = segue.destination as! ChatViewController
                // Since we don't have a backend for the purpose of this tutorial, the user ID is handled like this.
                destinationVC.userID = infoTextfield.text ?? ""
            }
        }

        override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
            let validCredentials = storageAccess.getCredentials()
            if let text = infoTextfield.text {
                if validCredentials?.contains(text) == true{
                    return true
                }
            }
            handleTechnicalError("Wrong username or password")
            return false
        }
    }

Next open the ChatViewController.swift.

Make sure to replace YOUR_INSTANCE_LOCATOR and YOUR_TOKEN_PROVIDER with yours, you can find these in the Chatkit SDK console of the created instance, under the Credentials tab, as presented in the image above. For the purpose of this tutorial test token provider was used. Otherwise for production environment, in order to generate your token, you should use the secret key.

The first step on viewDidLoad is the setup of the chatManager. The chatInstanceLocator , chatTokenProvider and the userID entered at IntroViewController.swift are used for creating the ChatManager instance.

In the method call connect(delegate: PCChatManagerDelegate) we send an instance of ChatManagerDelegate , whose implementation can be easily extended with PCChatManagerDelegate methods if we need additional functionality in our chat feature. For more information on those methods, you can visit the following documentation. Finally when the currentUser is initialized we either subscribe or join a room for that user.

    // ChatViewController.swift

    import UIKit
    import PusherChatkit

    /// Custom class serving as PCChatManagerDelegate, needed for ChatManager setup
    class ChatManagerDelegate: PCChatManagerDelegate {}

    /// Representing the chat UI and handling the setup of ChatManager and chat actions
    class ChatViewController: AbstractViewController, PCRoomDelegate, ResendChatMessage {

        // IBOutlets
        @IBOutlet weak var tableView: UITableView!
        @IBOutlet weak var chatTextField: UITextField!
        @IBOutlet weak var sendButton: UIButton!

        // Delegates
        private var chatTableViewDelegate: ChatTableViewDelegate?

        // Chat vars
        var chatManager: ChatManager!
        var currentUser: PCCurrentUser?
        var chatManagerDelegate: PCChatManagerDelegate?
        var messages: [ChatMessage] = []
        var defaultFrame: CGRect!
        var userID = ""

        // Constants
        let chatInstanceLocator = "YOUR_INSTANCE_LOCATOR"
        let chatTokenProvider =  "YOUR_TOKEN_PROVIDER"

        override func viewDidLoad() {
            super.viewDidLoad()
            setupChatManager()
            setupTableViewDelegateForChat()
        }

        private func setupTableViewDelegateForChat() {
            chatTableViewDelegate = ChatTableViewDelegate.init(tableView, messages, self)
            tableView.reloadData()
        }

        func setupChatManager() {
            chatManager = ChatManager(
                instanceLocator: chatInstanceLocator,
                tokenProvider: PCTokenProvider(url: chatTokenProvider),
                userID: userID
            )

            self.chatManagerDelegate = ChatManagerDelegate()
            guard let chatManagerDelegate = self.chatManagerDelegate else { return }
            chatManager.connect(delegate: chatManagerDelegate) { [unowned self] currentUser, error in
                guard error == nil else {
                    self.handleTechnicalError("Error connecting")
                    return
                }
                guard let currentUser = currentUser else { return }
                self.currentUser = currentUser
                guard let rooms = self.currentUser?.rooms else { return }
                if rooms.count > 0 {
                    self.subscribeUser(to: rooms[0])

                } else {
                    currentUser.getJoinableRooms(completionHandler: { (rooms, error) in
                        guard let rooms = rooms else { return }
                        self.joinUser(to: rooms[0])
                    })
                }
            }
        }

        private func subscribeUser(to room: PCRoom) {
            currentUser?.subscribeToRoomMultipart(
                room: room,
                roomDelegate: self,
                messageLimit: 0
            ) { error in
                guard error == nil else {
                    self.handleTechnicalError("Error subscribing to room")
                    return
                }
                self.fetchMessages()
            }
        }

        private func joinUser(to room: PCRoom) {
            currentUser?.joinRoom(room,
                                  completionHandler: { (room, error) in
                                    guard error == nil else {
                                        self.handleTechnicalError("Error subscribing to room")
                                        return
                                    }
                                    self.fetchMessages()
            })
        }

Fetch all messages

The first method called after the setup is fetchMessages.

    // ChatViewController.swift

    private func fetchMessages() {
            guard let currentUser = self.currentUser else {return}
            // Initialize with cached and failed messages
            DispatchQueue.main.async {
                var chatMesssages = CoreDataService.fetchRecords()
                if let room = currentUser.rooms.first {
                    currentUser.fetchMultipartMessages(room) { (messages, error) in
                        if let messages = messages {
                            for message in messages {
                                chatMesssages?.append(UtilFunctions.convertToChatMessage(message, currentUser))
                            }
                        }
                        self.sort(messages: chatMesssages)
                    }
                }
            }
        }

In this method the local cache system is starting to show.
When creating the project from Xcode there is an option to select Use Core Data. This generates code in AppDelegate.swift and creates .xcdatamodeld where we can define the structure and relationships of our entities.

The .xcdatamodeld in this project:

For using the core data there is a custom class CoreDataService, responsible for fetching the records, deleting them and saving new ones.

    // CoreDataService.swift

    import Foundation
    import UIKit
    import CoreData

    /// Enum defining the ChatMessage status
    enum ChatMessageStatus: String {
        case sent
        case notSent
    }

    /// Defines the structure for the Messages entity. Allowes easier manipulation with the data.
    struct ChatMessage {
        var id: Int
        var text: String
        var timestamp: Date
        var senderId: String
        var isReceived: Bool
        var status: ChatMessageStatus
    }

    /// Custom util class for saving, fetching and deleting records from Core data entity
    class CoreDataService {

        /// Maps and saves ChatMessage object as new record in Messages entity
        static func saveNewRecord(message: ChatMessage) {
            let appDelegate = UIApplication.shared.delegate as! AppDelegate
            let context = appDelegate.persistentContainer.viewContext

            let entity = NSEntityDescription.entity(forEntityName: "Messages", in: context)
            let messages = NSManagedObject(entity: entity!, insertInto: context)
            messages.setValue(message.text, forKey: "text")
            messages.setValue(message.timestamp, forKey: "timestamp")
            messages.setValue(message.senderId, forKey: "senderId")
            messages.setValue(message.id, forKey: "id")
            messages.setValue(message.isReceived, forKey: "isReceived")
            messages.setValue(message.status.rawValue, forKey: "status")
            do {
                try context.save()
            } catch {
                print("Failed saving")
            }
        }

        /// Delete record from Messages entity for specific index
        static func deleteRecord(with id: Int) {
            let appDelegate = UIApplication.shared.delegate as! AppDelegate
            if let result = readFromMessagesEntitity() {
                for data in result {
                    if data.value(forKey: "id") as! Int == id {
                        appDelegate.persistentContainer.viewContext.delete(data)
                    }
                }
            }
        }

        /// Fetches all messages saved. Cached messages. The result is mapped as [ChatMessage]
        static func fetchRecords() ->  [ChatMessage]? {
            if let result = readFromMessagesEntitity() {
                var messages = [ChatMessage]()
                for data in result {
                    let message = ChatMessage.init(id: data.value(forKey: "id") as! Int,
                                                   text: data.value(forKey: "text") as! String,
                                                   timestamp: data.value(forKey: "timestamp") as! Date,
                                                   senderId: data.value(forKey: "senderId") as! String,                     isReceived: data.value(forKey: "isReceived") as! Bool,
                                                   status: ChatMessageStatus.init(rawValue: data.value(forKey: "status") as! String) ?? .notSent)
                    messages.append(message)
                }
                return messages
            }
            return nil
        }

        private static func readFromMessagesEntitity() -> [NSManagedObject]? {
            let appDelegate = UIApplication.shared.delegate as! AppDelegate
            let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Messages")
            request.returnsObjectsAsFaults = false
            do {
                let result = try appDelegate.persistentContainer.viewContext.fetch(request)
                return result as? [NSManagedObject]
            } catch {
                return nil
            }
        }
    }

When calling fetchRecords() it returns all of the cached messages, loaded from the application persistent container. They are presented as array of ChatMessage, mapping the structure of our Messages entity. Then by calling fetchMultipartMessages(_ room: PCRoom) all of the successfully exchanged messages are fetched. But since these are an array of PCMultipartMessage object we use a util method, to do the mapping to ChatMessage . The util method responsible for the conversion can be found in UtilFunctions.swift.

    // UtilFunctions.swift

    /// Converts PCMultipartMessage recieved from the Chatkit SDK to our mapping ChatMessage.
        /// We only support inline PCMultipartPayload in this tutorial
        static func convertToChatMessage(_ message: PCMultipartMessage,
                                         _ currentUser: PCCurrentUser) -> ChatMessage {
            let payload = message.parts[0].payload
            var content = ""
            switch payload {
            case .inline(let payload):
                content = payload.content
            default: break
            }
            return ChatMessage.init(id: message.id,
                                    text: content,
                                    timestamp: dateFormatter.date(from:  message.createdAt) ?? Date(),
                                    senderId: message.sender.id,
                                    isReceived: currentUser.id != message.sender.id,
                                    status: .sent)
        }

Finally after all messages are fetched, they are sorted in descending order by timestamp.

    // ChatViewController.swift

    private func sort(messages: [ChatMessage]?) {
            if let messages = messages {
                self.messages = messages.sorted(by: { $0.timestamp.compare($1.timestamp) == .orderedDescending })
                DispatchQueue.main.async {
                    self.chatTableViewDelegate?.update(self.messages)
                    self.tableView.reloadData()
                }
            }
        }

Next they are passed to ChatTableViewDelegate to use the UITableViewDelegate and UITableViewDataSource methods, responsible to UI representation, layout and action handling of the table view.

Saving messages

The second important part of this process is saving of a failed message. When the send button is pressed the sendMessage(text: String) is invoked. This will check for internet connection and if its stable, sendMultipartMessage is called. If there is no internet connection or an error occurred while sending the message saveNotSentRecord is called. This creates a new ChatMessage object which is saved to Messages entity, and visually this message is presented on the bottom of the screen.

    // ChatViewController.swift 

    // MARK: IBAction
        @IBAction func sendMessageButton(_ sender: Any) {
            guard let message = self.chatTextField.text else { return }
            sendMessage(text: message)
            self.chatTextField.text?.removeAll()
        }

        private func sendMessage(text: String) {
            if !Reachability.isConnectedToNetwork(){
                saveNotSentRecord(for: text)
            } else {
                guard let currentUser = self.currentUser else {return}
                let partInlineRequest = PCPartInlineRequest.init(content: text)
                let partRequest = PCPartRequest.init(PCPartRequestType.inline(partInlineRequest))
                currentUser.sendMultipartMessage(roomID: currentUser.rooms.first?.id ?? "",
                                                 parts: [partRequest]) { (messageId, error) in
                                                    guard error == nil else {
                                                        self.saveNotSentRecord(for: text)
                                                        return
                                                    }
                }
            }
        }

        private func saveNotSentRecord(for text: String) {
            DispatchQueue.main.async {
                let cachedMessage = ChatMessage.init(id: UtilFunctions.generateUUID(),
                                                     text: text,
                                                     timestamp: Date(),
                                                     senderId: self.userID,
                                                     isReceived: false,
                                                     status: .notSent)
                CoreDataService.saveNewRecord(message: cachedMessage)

                self.messages.insert(cachedMessage, at: 0)
                self.chatTableViewDelegate?.update(self.messages)
                self.tableView.reloadData()
            }
        }

        // MARK: PCRoomDelegate
        func onMultipartMessage(_ message: PCMultipartMessage) {
            guard let currentUser = self.currentUser else { return }
            messages.insert(UtilFunctions.convertToChatMessage(message, currentUser), at: 0)
            DispatchQueue.main.async {
                self.chatTableViewDelegate?.update(self.messages)
                self.tableView.reloadData()
            }
        }

In ChatTableViewDelegate.swift there is logic to handle when a message is not sent, that ensures a different visual is presented and if the user taps it, there is ResendChatMessage delegate which will be notified. Here that is the ChatViewController.swift.

    // ChatTableViewDelegate.swift 

    // MARK: ResendChatMessage
        func resend(message: ChatMessage,
                    at index: Int) {
            if message.senderId != self.currentUser?.id {
                return
            }
            self.messages.remove(at: index)
            CoreDataService.deleteRecord(with: message.id)
            sendMessage(text: message.text)
        }

This method checks whether the message sender is the same as the current sender. Then removes the message from the messages array and deletes it from Messages entity. By calling sendMessage, the whole process repeats itself. Therefore if the send is unsuccessful, the message is saved once again to Messages entity and the user can resend it once again. But if the send is successful the UI is updated and the message is presented as such on the bottom of the chat.

Conclusion

I hope you find this tutorial helpful. The whole code can be downloaded from the following GitHub repo. You should have a better overview of using the Chatkit iOS SDK and of one of the approaches when implementing local cache in your iOS Application. Also you can read the rest of the Chatkit documentation for the iOS SDK and implement more of the functionalities it offers.

Clone the project repository
  • iOS
  • Swift
  • Chat
  • Chatkit

Products

  • Channels
  • Chatkit
  • Beams

© 2019 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.