In this article by Hossam Ghareeb, the author of the book, iOS Programming Cookbook, we will cover the recipe Integrating iMessage app with iMessage app.
(For more resources related to this topic, see here.)
Integrating iMessage app with iMessage app
Using iMessage apps will let users use your apps seamlessly from iMessage without having to leave the iMessage. Your app can share content in the conversation, make payment, or do any specific job that seems important or is appropriate to do within a Messages app.
Getting ready
Similar to the Stickers app we created earlier, you need Xcode 8.0 or later version to create an iMessage app extension and you can test it easily in the iOS simulator. The app that we are going to build is a Google drive picker app. It will be used from an iMessage extension to send a file to your friends just from Google Drive.
Before starting, ensure that you follow the instructions in Google Drive API for iOS from https://developers.google.com/drive/ios/quickstart to get a client key to be used in our app.
Installing the SDK in Xcode will be done via CocoaPods. To get more information about CocoaPods and how to use it to manage dependencies, visit https://cocoapods.org/ .
How to do it…
We Open Xcode and create a new iMessage app, as shown, and name itFiles Picker:
Now, let's install Google Drive SDK in iOS using CocoaPods. Open terminal and navigate to the directory that contains your Xcode project by running this command:
cd path_to_directory
Run the following command to create a Pod file to write your dependencies:
Pod init
It will create a Pod file for you. Open it via TextEdit and edit it to be like this:
use_frameworks!
target 'PDFPicker' do
end
target 'MessagesExtension' do
pod 'GoogleAPIClient/Drive', '~> 1.0.2'
pod 'GTMOAuth2', '~> 1.1.0'
end
Then, close the Xcode app completely and run the pod install command to install the SDK for you.
A new workspace will be created. Open it instead of the Xcode project itself.
Prepare the client key from the Google drive app you created as we mentioned in the Getting ready section, because we are going to use it in the Xcode project.
Open MessagesViewController.swift and add the following import statements:
import GoogleAPIClient
import GTMOAuth2
Add the following private variables just below the class declaration and embed your client key in the kClientID constant, as shown:
private let kKeychainItemName = "Drive API"
private let kClientID = "Client_Key_Goes_HERE"
private let scopes = [kGTLAuthScopeDrive]
private let service = GTLServiceDrive()
Add the following code in your class to request authentication to Google drive if it's not authenticated and load file info:
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
if let auth =
GTMOAuth2ViewControllerTouch.authForGoogleFromKeychain(forName:
kKeychainItemName,
clientID: kClientID,
clientSecret: nil)
{
service.authorizer = auth
}
}
// When the view appears, ensure that the Drive API service is
authorized
// and perform API calls
override func viewDidAppear(_ animated: Bool) {
if let authorizer = service.authorizer,
canAuth = authorizer.canAuthorize where canAuth {
fetchFiles()
} else {
present(createAuthController(), animated: true, completion: nil)
}
}
// Construct a query to get names and IDs of 10 files using the Google
Drive API
func fetchFiles() {
print("Getting files...")
if let query = GTLQueryDrive.queryForFilesList(){
query.fields = "nextPageToken, files(id, name, webViewLink,
webContentLink, fileExtension)"
service.executeQuery(query, delegate: self, didFinish:
#selector(MessagesViewController.displayResultWithTicket(ticket:finishedWit
hObject:error:)))
}
}
// Parse results and display
func displayResultWithTicket(ticket : GTLServiceTicket,
finishedWithObject response :
GTLDriveFileList,
if let error = error {
showAlert(title: "Error", message: error.localizedDescription)
return
}
var filesString = ""
let files = response.files as! [GTLDriveFile]
if !files.isEmpty{
filesString += "Files:n"
for file in files{
filesString += "(file.name) ((file.identifier)
((file.webViewLink) ((file.webContentLink))n"
}
} else {
filesString = "No files found."
}
print(filesString)
}
// Creates the auth controller for authorizing access to Drive API
private func createAuthController() -> GTMOAuth2ViewControllerTouch {
let scopeString = scopes.joined(separator: " ")
return GTMOAuth2ViewControllerTouch(
scope: scopeString,
clientID: kClientID,
clientSecret: nil,
keychainItemName: kKeychainItemName,
delegate: self,
finishedSelector:
#selector(MessagesViewController.viewController(vc:finishedWithAuth:error:)
)
)
}
// Handle completion of the authorization process, and update the Drive
API
// with the new credentials.
func viewController(vc : UIViewController,
finishedWithAuth authResult :
GTMOAuth2Authentication, error : NSError?) {
if let error = error {
service.authorizer = nil
showAlert(title: "Authentication Error", message:
error.localizedDescription)
return
}
service.authorizer = authResult
dismiss(animated: true, completion: nil)
fetchFiles()
}
// Helper for showing an alert
func showAlert(title : String, message: String) {
let alert = UIAlertController(
title: title,
message: message,
preferredStyle: UIAlertControllerStyle.alert
)
let ok = UIAlertAction(
title: "OK",
style: UIAlertActionStyle.default,
handler: nil
)
alert.addAction(ok)
self.present(alert, animated: true, completion: nil)
}
The code now requests authentication, loads files, and then prints them in the debug area. Now, try to build and run, you will see the following:
Click on the arrow button in the bottom right corner to maximize the screen and try to log in with any Google account you have.
Once the authentication is done, you will see the files' information printed in the debug area.
Now, let's add a table view that will display the files' information and once a user selects a file, we will download this file to send it as an attachment to the conversation. Now, open theMainInterface.storyboard, drag a table view from Object Library, and add the following constraints:
Set the delegate and data source of the table view from interface builder by dragging while holding down the Ctrl key to theMessagesViewController. Then, add an outlet to the table view, as follows, to be used to refresh the table with the files:
Drag a UITabeView cell from Object Library and drop it in the table view. For Attribute Inspector, set the cell style to Basic and the identifier to cell.
Now, return to MessagesViewController.swift. Add the following property to hold the current display files:
private var currentFiles = [GTLDriveFile]()
Edit the displayResultWithTicket function to be like this:
// Parse results and display
func displayResultWithTicket(ticket : GTLServiceTicket,
finishedWithObject response :
GTLDriveFileList,
error : NSError?) {
if let error = error {
showAlert(title: "Error", message: error.localizedDescription)
return
}
var filesString = ""
let files = response.files as! [GTLDriveFile]
self.currentFiles = files
if !files.isEmpty{
filesString += "Files:n"
for file in files{
filesString += "(file.name) ((file.identifier)
((file.webViewLink) ((file.webContentLink))n"
}
} else {
filesString = "No files found."
}
print(filesString)
self.filesTableView.reloadData()
}
Now, add the following method for the table view delegate and data source:
// MARK: - Table View methods -
func tableView(_ tableView: UITableView, numberOfRowsInSection section:
Int) -> Int {
return self.currentFiles.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath:
IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell")
let file = self.currentFiles[indexPath.row]
cell?.textLabel?.text = file.name
return cell!
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath:
IndexPath) {
let file = self.currentFiles[indexPath.row]
// Download File here to send as attachment.
if let downloadURLString = file.webContentLink{
let url = NSURL(string: downloadURLString)
if let name = file.name{
let downloadedPath = (documentsPath() as
NSString).appendingPathComponent("(name)")
let fetcher = service.fetcherService.fetcher(with: url as!
URL)
let destinationURL = NSURL(fileURLWithPath: downloadedPath)
as URL
fetcher.destinationFileURL = destinationURL
fetcher.beginFetch(completionHandler: { (data, error) in
if error == nil{
self.activeConversation?.insertAttachment(destinationURL,
withAlternateFilename: name, completionHandler: nil)
}
})
}
}
}
private func documentsPath() -> String{
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory,
.userDomainMask, true)
return paths.first ?? ""
}
Now, build and run the app, and you will see the magic: select any file and the app will download and save it to the local disk and send it as an attachment to the conversation, as illustrated:
How it works…
We started by installing the Google Drive SDK to the Xcode project. This SDK has all the APIs that we need to manage drive files and user authentication. When you visit the Google developers' website, you will see two options to install the SDK: manually or using CocoaPods. I totally recommend using CocoaPods to manage your dependencies as it is simple and efficient.
Once the SDK has been installed via CocoaPods, we added some variables to be used for the Google Drive API and the most important one is the client key. You can access this value from the project you have created in the Google Developers Console.
In the viewDidLoad function, first, we check if we have an authentication saved in KeyChain, and then, we use it. We can do that by calling GTMOAuth2ViewControllerTouch.authForGoogleFromKeychain, which takes the Keychain name and client key as parameters to search for authentication. It's useful as it helps you remember the last authentication and there is no need to ask for user authentication again if a user has already been authenticated before.
In viewDidAppear, we check if a user is already authenticated; so, in that case, we start fetching files from the drive and, if not, we display the authentication controller, which asks a user to enter his Google account credentials.
To display the authentication controller, we present the authentication view controller created in the createAuthController() function. In this function, the Google Drive API provides us with the GTMOAuth2ViewControllerTouch class, which encapsulates all logic for Google account authentication for your app. You need to pass the client key for your project, keychain name to save the authentication details there, and the finished viewController(vc : UIViewController,
finishedWithAuth authResult : GTMOAuth2Authentication, error : NSError?) selector that will be called after the authentication is complete. In that function, we check for errors and if something wrong happens, we display an alert message to the user. If no error occurs, we start fetching files using the fetchFiles() function.
In the fetchFiles() function, we first create a query by calling GTLQueryDrive.queryForFilesList(). The GTLQueryDrive class has all the information you need about your query, such as which fields to read, for example, name, fileExtension, and a lot of other fields that you can fetch from the Google drive. You can specify the page size if you are going to call with pagination, for example, 10 by 10 files. Once you are happy with your query, execute it by calling service.executeQuery, which takes the query and the finished selector to be called when finished. In our example, it will call the displayResultWithTicket function, which prepares the files to be displayed in the table view. Then, we call self.filesTableView.reloadData() to refresh the table view to display the list of files.
In the delegate function of table view didSelectRowAt indexPath:, we first read the webContentLink property from the GTLDriveFile instance, which is a download link for the selected file. To fetch a file from the Google drive, the API provides us with GTMSessionFetcher that can fetch a file and write it directly to a device's disk locally when you pass a local path to it. To create GTMSessionFetcher, use the service.fetcherService factory class, which gives you instance to a fetcher via the file URL. Then, we create a local path to the downloaded file by appending the filename to the documents path of your app and then, pass it to fetcher via the following command:
fetcher.destinationFileURL = destinationURL
Once you set up everything, call fetcher.beginFetch and pass a completion handler to be executed after finishing the fetching. Once the fetching is completed successfully, you can get a reference to the current conversation so that you can insert the file to it as an attachment. To do this, just call the following function:
self.activeConversation?.insertAttachment(destinationURL,
withAlternateFilename: name, completionHandler: nil)
There's more…
Yes, there's more that you can do in the preceding example to make it fancier and more appealing to users. Check the following options to make it better:
You can show a loading indicator or progress bar while a file is downloading.
Checks if the file is already downloaded, and if so, there is no need to download it again.
Adding pagination to request only 10 files at a time.
Options to filter documents by type, such as PDF, images, or even by date.
Search for a file in your drive.
Showing Progress indicator
As we said, one of the features that we can add in the preceding example is the ability to show a progress bar indicating the downloading progress of a file. Before starting how to show a progress bar, let's install a library that is very helpful in managing/showing HUD indicators, which is MBProgressHUD. This library is available in GitHub at https://github.com/jdg/MBProgressHUD.
As we agreed before, all packages are managed via CocoaPods, so now, let's install the library via CocoaPods, as shown:
Open the Podfile and update it to be as follows:
use_frameworks!
target 'PDFPicker' do
end
target 'MessagesExtension' do
pod 'GoogleAPIClient/Drive', '~> 1.0.2'
pod 'GTMOAuth2', '~> 1.1.0'
pod 'MBProgressHUD', '~> 1.0.0'
end
Run the following command to install the dependencies:
pod install
Now, at the top of the MessagesViewController.swift file, add the following import statement to import the library:
Now, let's edit the didSelectRowAtIndexPath function to be like this:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath:
IndexPath) {
let file = self.currentFiles[indexPath.row]
// Download File here to send as attachment.
if let downloadURLString = file.webContentLink{
let url = NSURL(string: downloadURLString)
if let name = file.name{
let downloadedPath = (documentsPath() as
NSString).appendingPathComponent("(name)")
let fetcher = service.fetcherService.fetcher(with: url as!
URL)
let destinationURL = NSURL(fileURLWithPath: downloadedPath)
as URL
fetcher.destinationFileURL = destinationURL
var progress = Progress()
let hud = MBProgressHUD.showAdded(to: self.view, animated:
true)
hud.mode = .annularDeterminate;
hud.progressObject = progress
fetcher.beginFetch(completionHandler: { (data, error) in
if error == nil{
hud.hide(animated: true)
self.activeConversation?.insertAttachment(destinationURL,
withAlternateFilename: name, completionHandler: nil)
}
})
fetcher.downloadProgressBlock = { (bytes, written,
expected) in
let p = Double(written) * 100.0 / Double(expected)
print(p)
progress.totalUnitCount = expected
progress.completedUnitCount = written
}
}
}
}
First, we create an instance of MBProgressHUD and set its type to annularDeterminate, which means to display a circular progress bar. HUD will update its progress by taking a reference to the NSProgress object. Progress has two important variables to determine the progress value, which are totalUnitCount and completedUnitCount. These two values will be set inside the progress completion block, downloadProgressBlock, in the fetcher instance. HUD will be hidden in the completion block that will be called once the download is complete.
Now build and run; after authentication, when you click on a file, you will see something like this:
As you can see, the progressive view is updated with the percentage of download to give the user an overview of what is going on.
Request files with pagination
Loading all files at once is easy from the development side, but it's incorrect from the user experience side. It will take too much time at the beginning when you get the list of all the files and it would be great if we could request only 10 files at a time with pagination. In this section, we will see how to add the pagination concept to our example and request only 10 files at a time. When a user scrolls to the end of the list, we will display a loading indicator, call the next page, and append the results to our current results. Implementation of pagination is pretty easy and requires only a few changes in our code. Let's see how to do it:
We will start by adding the progress cell design in MainInterface.storyboard. Open the design of MessagesViewController and drag a new cell along with our default cell.
Drag a UIActivityIndicatorView from ObjectLibrary and place it as a subview to the new cell.
Add center constraints to center it horizontally and vertically as shown:
Now, select the new cell and go to attribute inspector to add an identifier to the cell and disable the selection as illustrated:
Now, from the design side, we are ready. Open MessagesViewController.swift to add some tweaks to it. Add the following two variables to the list of our current variables:
private var doneFetchingFiles = false
private var nextPageToken: String!
The doneFetchingFiles flag will be used to hide the progress cell when we try to load the next page from Google Drive and returns an empty list. In that case, we know that we are done with the fetching files and there is no need to display the progress cell any more.
The nextPageToken contains the token to be passed to the GTLQueryDrive query to ask it to load the next page.
Now, go to the fetchFiles() function and update it to be as shown:
func fetchFiles() {
print("Getting files...")
if let query = GTLQueryDrive.queryForFilesList(){
query.fields = "nextPageToken, files(id, name, webViewLink,
webContentLink, fileExtension)"
query.mimeType = "application/pdf"
query.pageSize = 10
query.pageToken = nextPageToken
service.executeQuery(query, delegate: self, didFinish:
#selector(MessagesViewController.displayResultWithTicket(ticket:finishedWit
hObject:error:)))
}
}
The only difference you can note between the preceding code and the one before that is setting the pageSize and pageToken. For pageSize, we set how many files we require for each call and for pageToken, we pass the token to get the next page. We receive this token as a response from the previous page call. This means that, at the first call, we don't have a token and it will be passed as nil.
Now, open the displayResultWithTicket function and update it like this:
// Parse results and display
func displayResultWithTicket(ticket : GTLServiceTicket,
finishedWithObject response :
GTLDriveFileList,
error : NSError?) {
if let error = error {
showAlert(title: "Error", message: error.localizedDescription)
return
}
var filesString = ""
nextPageToken = response.nextPageToken
let files = response.files as! [GTLDriveFile]
doneFetchingFiles = files.isEmpty
self.currentFiles += files
if !files.isEmpty{
filesString += "Files:n"
for file in files{
filesString += "(file.name) ((file.identifier)
((file.webViewLink) ((file.webContentLink))n"
}
} else {
filesString = "No files found."
}
print(filesString)
self.filesTableView.reloadData()
}
As you can see, we first get the token that is to be used to load the next page. We get it by calling response.nextPageToken and setting it to our new nextPageToken property so that we can use it while loading the next page. The doneFetchingFiles will be true only if the current page we are loading has no files, which means that we are done. Then, we append the new files we get to the current files we have.
We don't know when to fire the calling of the next page. We will do this once the user scrolls down to the refresh cell that we have. To do so, we will implement one of the UITableViewDelegate methods, which is willDisplayCell, as illustrated:
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell,
forRowAt indexPath: IndexPath) {
if !doneFetchingFiles && indexPath.row == self.currentFiles.count {
// Refresh cell
fetchFiles()
return
}
}
For any cell that is going to be displayed, this function will be triggered with indexPath of the cell. First, we check if we are not done with the fetching files and the row is equal to the last row, then, we fire fetchFiles() again to load the next page.
As we added a new refresh cell at the bottom, we should update our UITableViewDataSource functions, such as numbersOfRowsInSection and cellForRow. Check our updated functions, shown as follows:
func tableView(_ tableView: UITableView, numberOfRowsInSection section:
Int) -> Int {
return doneFetchingFiles ? self.currentFiles.count :
self.currentFiles.count + 1
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath:
IndexPath) -> UITableViewCell {
if !doneFetchingFiles && indexPath.row == self.currentFiles.count{
return tableView.dequeueReusableCell(withIdentifier:
"progressCell")!
}
let cell = tableView.dequeueReusableCell(withIdentifier: "cell")
let file = self.currentFiles[indexPath.row]
cell?.textLabel?.text = file.name
return cell!
}
As you can see, the number of rows will be equal to the current files' count plus one for the refresh cell. If we are done with the fetching files, we will return only the number of files.
Now, everything seems perfect. When you build and run, you will see only 10 files listed, as shown:
And when you scroll down you would see the progress cell that and 10 more files will be called.
Summary
In this article, we learned how to integrate iMessage app with iMessage app.
Resources for Article:
Further resources on this subject:
iOS Security Overview [article]
Optimizing JavaScript for iOS Hybrid Apps [article]
Testing our application on an iOS device [article]
Read more