Using SiriKit in iOS Apps
Morgan Collino
Morgan Collino

Using SiriKit in iOS Apps

Morgan Collino, iOS Engineer

Last year during WWDC, Apple presented a new framework named SiriKit that allows developers to play with Siri, the “personal assistant” from Apple. This was the first time that apps could interact with Siri and, up to that point, only a few features had been available via Siri (phone call, text message via iMessage, etc). With many competitors on the market—such as Alexa from Amazon, Cortana from Microsoft, and Google Assistant—Apple needed to provide their own solution.

How does SiriKit work?

The user interacts with Siri to formulate a request. Siri then parses the request for its intent and redirects to the right app. If the intent can be handled by that app, Siri redirects the intent to the corresponding app extension.

The Siri app extension then advises Siri, and thus the user, if it needs more information or if the values are incorrect or correct based on app logic.

There is a back and forth between the user, Siri, and the app extension until the intent is finalized and correct or until the user cancels the intent.

What can we do with SiriKit?

SiriKit provides a range of intents that can be used by the SiriKit extension. For iOS 10, only 8 of them are represented. Two were added for iOS 11 along with enhancements for some existing intents. Apple’s strategy, which I believe is the best approach, is to move carefully and with baby steps on the personal assistant game.

With SiriKit you can send a message/read messages, call someone via VoIP, check your pictures, send a payment, get a ride, communicate with your car via CarPlay, and start a workout session. As of iOS 11, you can also create reminders, update a To Do list, display a QR Code and ask to scan a code. And—I almost forgot—the restaurant reservations intent, which requires you to work with Apple Maps before your application can use it.

Some intents are available for specific usage, such as Car commands and CarPlay integration with Siri for car manufacturers. Users can change radio stations, climate control, and seat settings with the CarPlay intent and can manage their vehicle door locks and get the vehicle’s status with the Car command intent.

Only apps including the kind of features listed previously can meaningfully implement SiriKit. Which means that if your app doesn’t have any of the features that suit existing intents, you can will have to wait until a new update of SiriKit.

It’s also cool to note that SiriKit is available to WatchOS 3.2 so users can interact via their Apple Watch.

What’s required to implement SiriKit?

You need at least iOS 10. If you’re still using Xcode 8, it will require a lot of patience and maybe even a little anger management. Fortunately, Xcode 9 makes it easier and smoother. The Apple developers made a lot of improvements; it went from being a nightmare to debug to being just a bit annoying when attaching/detaching processes.

Of course, debugging an extension via Xcode has always been a hard task. The only solution I’ve found is to kill Xcode, the Simulator, or the app on simulator when there is an issue attaching/detaching the processes.

Implementation

How do we create/implement a SiriKit extension?

Here are the steps to create a SiriKit Extension.

  1. Create a new Target

2. Select “Intents Extension”

3. Fill out the information and click Finish

Message Extension

The intents extension creates two folders: one for the extension business logic and one with the extension UI.

Siri Extension

The Siri extension communicates between Siri and your app services. Siri receives instructions from the user and outputs an action via the intent. The role of the intent is to validate, invalidate, or ask for more information before continuing the intent.

The entry point of the SiriKit extension is the IntentHandler file and class. The main method of this class in `func handler(for intent: INIntent) -> Any` which returns a specific subclass of INExtension per intent. For example, you might want to have different class handling messaging intents and the payment intents. For this tutorial, I will demonstrate how to create a send messages intent & search messages intent.

class IntentHandler: INExtension {
    
    override func handler(for intent: INIntent) -> Any {
        // This is the default implementation.  If you want different objects to handle different intents,
        // you can override this and return the handler you want for that particular intent.
        if intent is INSendMessageIntent || intent is INSearchForMessagesIntent {
            return MessageIntent()
        }
        
        return self
    }
    
}

So first, let’s create a subclass of INExtension.

import Intents

class MessageIntent: INExtension {}

Send Messages Intent

Resolution methods are there to provide additional information about the intent. They are optional but are generally needed to give your intent context.

extension MessageIntent: INSendMessageIntentHandling {

In the example below, we’re fetching the potential contacts based on the intent information/context. We can recommend that Siri ask for more information if we find no contacts with the name given by the intent, ask to choose between duplicate contacts, or return success if a unique contact with the given name has been found.

    // Implement resolution methods to provide additional information about your intent (optional).
    func resolveRecipients(forSendMessage intent: INSendMessageIntent, with completion: @escaping ([INPersonResolutionResult]) -> Void) {
        if let recipients = intent.recipients {
            
            // If no recipients were provided we'll need to prompt for a value.
            if recipients.count == 0 {
                completion([INPersonResolutionResult.needsValue()])
                return
            }
            
            var resolutionResults = [INPersonResolutionResult]()
            for recipient in recipients {
                // Look for potential matching contacts here.
                let matchingContacts = ContactManager.sharedContactManager.getContactsBy(name: recipient.displayName)
                
                if matchingContacts.count == 0 {
                    // We have no contacts matching the description provided
                    resolutionResults += [INPersonResolutionResult.unsupported()]
                } else if matchingContacts.count == 1 {
                    // We have exactly one matching contact
                    resolutionResults += [INPersonResolutionResult.success(with: matchingContacts.first!)]
                } else if matchingContacts.count > 1 {
                    // We need Siri's help to ask user to pick one from the matches
                    resolutionResults += [INPersonResolutionResult.disambiguation(with: matchingContacts)]
                }
            }
            completion(resolutionResults)
        }
    }

Like the resolution method for the recipients, this method checks if the value passed as text content is valid to be processed by the intent. In this case, we’re just checking if the text is not nil nor empty. If it is, we’re requiring Siri to ask back for the content.

    // You could use this function to give more context to the text you want to send via Siri/Your app.
    func resolveContent(forSendMessage intent: INSendMessageIntent, with completion: @escaping (INStringResolutionResult) -> Void) {
        if let text = intent.content, !text.isEmpty {
            completion(INStringResolutionResult.success(with: text))
        } else {
            completion(INStringResolutionResult.needsValue())
        }
    }

Once the resolution is completed (recipients, content, senders, etc.), perform validation on the intent and provide confirmation (e.g.—check if the user is connected).

    // Once resolution is completed, perform validation on the intent and provide confirmation (optional). 
    func confirm(sendMessage intent: INSendMessageIntent, completion: @escaping (INSendMessageIntentResponse) -> Void) {
        // Verify user is authenticated and your app is ready to send a message.
        
        let userActivity = NSUserActivity(activityType: NSStringFromClass(INSendMessageIntent.self))
        let response = INSendMessageIntentResponse(code: .ready, userActivity: userActivity)
        completion(response)
    }

Search Messages Intent

The handle method is called when the send message is being completed. You can incorporate logic to actually send a message via an API call/data manager event.

    // Handle the completed intent (required).
    
    func handle(sendMessage intent: INSendMessageIntent, completion: @escaping (INSendMessageIntentResponse) -> Void) {
        // Implement your application logic to send a message here.
        guard let firstRecipient = intent.recipients?.first else {
            return
        }
        
        // Put the logic to send your message here.
        let result = MessageManager.sharedMessageManager.sendMessage(message: intent.content!, to: firstRecipient.toContact())
        let responseSuccess = INSendMessageIntentResponseCode = result ? .success : .failure
        
        
        let userActivity = NSUserActivity(activityType: NSStringFromClass(INSendMessageIntent.self))
        let response = INSendMessageIntentResponse(code: responseSuccess, userActivity: userActivity)
        completion(response)
    }
    func handle(searchForMessages intent: INSearchForMessagesIntent, completion: @escaping (INSearchForMessagesIntentResponse) -> Void) {
        // Implement your application logic to find a message that matches the information in the intent.
        
        let userActivity = NSUserActivity(activityType: NSStringFromClass(INSearchForMessagesIntent.self))
        let response = INSearchForMessagesIntentResponse(code: .success, userActivity: userActivity)
        
        
        // Initialize with found message's attributes
        
        guard let firstContact = ContactManager.sharedContactManager.getContacts().first,
            let lastContact = ContactManager.sharedContactManager.getContacts().last else {
                completion(response)
                return
        }
        
        response.messages = [INMessage(
            identifier: "identifier",
            content: "I am so excited about SiriKit!",
            dateSent: Date(),
            sender: firstContact.toINPerson(),
            recipients: [lastContact.toINPerson()]
            )]
        completion(response)
    }

Pretty easy, right? Now, let’s make it prettier.

Siri UI Extension

The Siri UI Extension allows you to customize the design of your intent. Like iOS apps, you can use Storyboards to create your custom UI.

By default, the entry point of the UI is IntentViewController, but you can choose any class you like.

The class should extend the protocol INUIHostedViewControlling to receive callbacks from the given protocol and react based on the interaction made and context.

extension IntentViewController: INUIHostedViewControlling {

You have to declare the method `configure(with:, context:, completion:) -> Void` to display a custom UI based on the interaction.

    @available(iOS 10.0, *)
    func configure(with interaction: INInteraction, context: INUIHostedViewContext, completion: @escaping (CGSize) -> Void) {
        // Should not be called - By default is calling configureView
        fatalError()
    }

Since iOS 11, there is also another method `configureView(for parameters: of interaction: context:) -> Void` which works like the previous but takes an array of INParameter.

SiriKit passes parameter objects to you during the configuration of your interface. When configuring your interface, you can also create parameter objects to represent properties that you display in addition to the ones that SiriKit provides.

Use a parameter object to identify a property of an INInteraction object. To fetch the value of the property, use the parameterValue(for:) method of the INInteraction object.

Here, I’m retrieving the parameter based on the intent for which I want to customize the design:

    // Prepare your view controller for the interaction to handle.
    func configureView(for parameters: Set<INParameter>, of interaction: INInteraction, context: INUIHostedViewContext, completion: @escaping (Bool, Set<INParameter>, CGSize) -> Void) {
        
        let intentParameter = parameters.filter({ (parameter) -> Bool in
            return parameter.parameterClass == INSendMessageIntent.classForCoder()
        }).first
        
        guard let contentType = intentParameter else {
            completion(false, parameters, CGSize.zero)
            return
        }
        
        let size = displayFor(parameter: contentType, interaction: interaction)
        completion(true, parameters, size)
    }

Depending on the parameter class and key path, we can display a different view controller. The parameterKeyPath value depends on the state of your intent. For example, the parameterKeyPath value “recipients” is given right after the method `resolveRecipients(…)` is called. Same thing for “content”. When a message is sent, another “content” parameter is passed, but the value of the intent content may be incorrect. The value passed as content is from a previous Siri request.

    fileprivate func displayFor(parameter: INParameter, interaction: INInteraction) -> CGSize {
        switch parameter.parameterClass.description() {
        case "INSendMessageIntent":
            let sendMessageIntent = interaction.intent as! INSendMessageIntent
            
            if parameter.parameterKeyPath == "content",
                let content = sendMessageIntent.content {

                if interaction.intentHandlingStatus == .success {
                    displayMessageSentIntent(value: "")
                } else {
                    displaySendMessageIntent(value: content)
                }
                return contentSize
            } else if parameter.parameterKeyPath == "recipients" {
                let contact = sendMessageIntent.recipients?.first
                displayRecipientIntent(with: contact)
                return defaultSize
            }
        default:
            break
        }
        
        return CGSize.zero
    }
    private func displayRecipientIntent(with contact: INPerson?) {
        let recipientViewController = storyboard?.instantiateViewController(withIdentifier: "RecipientViewController") as! RecipientViewController
        let displayName = contact?.displayName ?? "somebody"
        recipientViewController.content = "Sending a message to \(displayName)"
        present(recipientViewController, animated: false, completion: nil)
    }
    
    private func displaySendMessageIntent(value: String?) {
        let messageViewController = storyboard?.instantiateViewController(withIdentifier: "MessageViewController") as! MessageViewController
        messageViewController.content = value
        present(messageViewController, animated: false, completion: nil)
    }
    
    private func displayMessageSentIntent(value: String) {
        let messageViewController = storyboard?.instantiateViewController(withIdentifier: "MessageViewController") as! MessageViewController
        messageViewController.content = "Message Sent"
        present(messageViewController, animated: false, completion: nil)
    }
    private var contentSize: CGSize {
        return CGSize(width: self.extensionContext!.hostedViewMinimumAllowedSize.width, height: self.extensionContext!.hostedViewMinimumAllowedSize.height + 60)
    }
    
    private var defaultSize: CGSize {
        return CGSize(width: self.extensionContext!.hostedViewMinimumAllowedSize.width, height: 40)
    }

Here is the storyboard with the custom “designs:

Requirements & Gotchas

Request Siri Authorization

To use Siri for your app, you can request Siri’s authorization from the app. If the user uses Siri directory to ask something from your app, Siri will ask also if your app can use Siri.

 

        INPreferences.requestSiriAuthorization { status in
            if status == .authorized {
                print("Yay Siri!")
            } else {
                print("Nope Siri!")
            }
        }

UI Content Size

A custom extension UI has a maximum and minimum content size. As you saw, apps can customize the Siri interface using an Intents UI extension. The extension extends a view controller whose view contains the custom content that you want Siri to display. The size of that view controller’s view must be greater than or equal to “hostedViewMinimumAllowedSize” and less than or equal to “hostedViewMaximumAllowedSize” from the class NSExtensionContext.

Configuration

As for location, Siri needs to have an usage description into the Info.plist of the extension.

To allow your app extension to support a given intent, you will have to add the intent’s name in the IntentsSupported array under NSExtension/NSExtensionAttributes in the Info.plist of the app extension. To customize a given intent, you will have to do the same in the Info.plist of the UI extension. See below for an example.

Security

You can disable intents while the device is locked by adding the intent’s name in the IntentsRestrictedWhileLocked array.

To add another level of security during the Siri request, you can use the LocalAuthentication API by asking for TouchId or passcode authentication.

Here is an example of how to implement this.

        let reason = "Allow Siri to send the message"
        LAContext().evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { (accessGranted, error) in
            var responseSuccess: INSendMessageIntentResponseCode = .failure
            if accessGranted {
                let result = MessageManager.sharedMessageManager.sendMessage(message: intent.content!, to: firstRecipient.toContact())
                responseSuccess = result ? .success : .failure
            }
            
            let userActivity = NSUserActivity(activityType: NSStringFromClass(INSendMessageIntent.self))
            let response = INSendMessageIntentResponse(code: responseSuccess, userActivity: userActivity)
            completion(response)
        }

Vocabulary

You can define app-specific terms that users can speak when making requests through Siri.

It lets you augment your app global vocabulary with terms that are both unique to your app and to the current user of your app. Registering custom terms provides Siri with hints it needs to apply those terms appropriately to the corresponding intent objects.

You may register custom terms only for specific types of content, including users of your app, custom workout names, or custom tags applied to a photo.

Testing

Since Xcode 9, you can edit your scheme and add text for the Siri Intent Query which will automatically be executed when the app launches on simulator or device. It’s a significant upgrade for testing Siri, as you won’t have to repeat yourself every time you want to test your Siri intent and look like a weirdo in front of your colleagues.

Conclusion

To sum up, adding Siri to your app allows your users to get quickly to the main features from their iOS devices, and the technical implementation doesn’t take much time.

Apple seems to be pushing Siri to attract more users to engage with it, even recently making a video with The Rock. Incorporating SiriKit in your app may be a great way to stay at the forefront of Apple’s technology innovation and users’ fingertips.