Earlier this summer at WWDC 2018, Apple announced Siri Shortcuts, a new Siri feature that will allow you to extend your app’s capabilities by building custom voice actions.

Siri Shortcuts will be available with the release of iOS 12 later this year.

Let’s get started

This tutorial will focus on understanding and developing a basic application, which will allow our users to book a test drive with their local car showroom through Siri Shortcuts.

To speed up the development process and solely focus on understanding Siri Shortcuts, I have prepared for you a sample project that can be downloaded here

The demo project currently contains 2 ViewControllers, a CatalogViewController for our car list, a BookingConfirmationViewController for our test drive booking confirmation and a DataManager.

Part 1: Enabling Siri Capabilities and adding Inters Extension

In order to be able to start developing our Siri Shortcuts feature, we need to enable Siri Capabilities in our app. You can do this by opening your project navigator in the “Capabilities” tab. Here you can scroll down to and turn “Siri” on.

Now that we have enabled Siri, we will need to add the IntentExtension and IntentExtensionUI targets to our app. To do so, click “File” in the Xcode menu, select “New” -> “Target” and then select the “Intent Extension” target. Name your extension SiriIntentExtension and make sure you have checked “Include Intent Extension UI”. This will include SiriIntentExtensionUI automatically to our app’s targets.

Part 2: Request Siri permissions

Include the NSSiriUsageDescription key in your iOS app’s Info.plist file and describes what our users will be allowing “Siri” interaction for.

Then in our CatalogViewController import IntentsUI and add the following functions and call requestSiriAuthorization in viewDidLoad. The requestSiriAuthorization function will ask our users on the first app open to allow access to Siri. Because our app’s main purpose is to demo Siri’s functionality, we can add allowSiriAlert function, that will be called if the user hasn’t allowed Siri permissions yet.

//if user hasn't already allowed Siri permisions, we request the user to authorize Siri
private func requestSiriAuthorization() {
    guard INPreferences.siriAuthorizationStatus() != .authorized else { return }

    INPreferences.requestSiriAuthorization { (status) in
        switch status {
        case .authorized, .notDetermined, .restricted:
            self.makeCatalogActivity()
        case .denied:
            self.allowSiriAlert()
        }
    }
}

private func allowSiriAlert() {
    let alert = UIAlertController(title: "Siri permision needed", message: nil, preferredStyle: .alert)
    let allow = UIAlertAction(title: "Allow", style: .default) { _ in
        guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { return }
        if UIApplication.shared.canOpenURL(settingsUrl) {
            UIApplication.shared.open(settingsUrl, options: [:], completionHandler: nil)
        } else {
            print("can't open path")
        }
    }

    let cancel = UIAlertAction(title: "Cancel", style: .default) { _ in
        alert.dismiss(animated: true, completion: nil)
    }

    alert.addAction(allow)
    alert.addAction(cancel)

    present(alert, animated: true, completion: nil)
}

Part 3: Donate Shortcuts

Before Siri can suggest shortcuts for our app, we need to tell Siri about the shortcuts through donations. An app should make donations each time a user performs an action in the app.

Currently there are two types of donations possible:

  • Donate a User Activity
  • Donate an Interaction

More information about this can be found here.

Part 4: Donating a User Activity

As suggested by Apple NSUserActivity provides a lightweight approach for making a donation that also integrates with other Apple features such as Handoff and Spotlight search. In our project we will use an NSUserActivity to allow our users to find the car catalog from Siri.

To get started define the activity as a type in the NSUserActivityTypes array in our Info.plist. The activity should have a reversed domain name which is unique within the list.

Now in our project, go ahead and create a new Swift file, called NSUserActivity+CatalogActivity. Here we will extend NSUserActivity to create our catalog activity.

To make it easier for future implementation of other custom activities we will create another NSUserActivity extension, where we will hold our identifiers needed for further development.

Your extension should look like this:

extension NSUserActivity {
    struct ActivityKeys {
        static let catalog = "catalogActivity"
    }

    struct ActivityTypes {
        static let catalog = "com.nodes.siriSugestions.demo.catalogActivity"
    }

    struct ActivityTitles {
        static let catalog = "Car Showroom Catalog"
    }

    struct SearchableKeywords {
        static let catalog = ["CAR", "SHOWROOM", "CATALOG", "CARS"]
    }

    struct SearchableName {
        static let catalog = "Car Showroom Catalog"
    }

    struct SearchableDescription {
        static let catalog = "Find the best cars for your testdrive"
    }

    struct InvocationPhrase {
        static let catalog = "Show Catalog"
    }
}

Switch back to NSUserActivity+CatalogActivity and start by extending NSUserActivity and import UIKit and CoreSpotlight. Now add the following static variable:

import CoreSpotlight
import UIKit

extension NSUserActivity {

    static var catalogActivity: NSUserActivity {
    let activity = NSUserActivity(activityType: NSUserActivity.ActivityTypes.catalog)
    activity.title = NSUserActivity.ActivityTitles.catalog
    activity.isEligibleForSearch = true
    activity.isEligibleForPrediction = true
    activity.persistentIdentifier = NSUserActivityPersistentIdentifier(NSUserActivity.ActivityKeys.catalog)
    activity.suggestedInvocationPhrase = NSUserActivity.InvocationPhrase.catalog

    #if os(iOS)
    let attributes = CSSearchableItemAttributeSet(itemContentType: NSUserActivity.ActivityTypes.catalog)
    attributes.keywords = NSUserActivity.SearchableKeywords.catalog
    attributes.displayName = NSUserActivity.SearchableName.catalog
    attributes.contentDescription = NSUserActivity.SearchableDescription.catalog

    activity.contentAttributeSet = attributes
    #endif

    return activity
    }

}

We have now created a custom NSUserActivity which is both eligible for search and prediction, with a custom suggestedInvocationPhrase to be suggested later for our users when creating a Voice Shortcut. As well we have added a contentAttributeSet to our activity. This will allow our users to find the activity as well via Core Spotlight.

We are now ready to donate this activity to Siri. For this switch back to our CatalogViewController and in our viewDidLoad call userActivity = NSUserActivity.catalogActivity. By simply assigning our custom activity to the ViewController we have now donated it to Siri.

Because the Shortcuts app is not yet released at the time of this tutorial, our users can create a voice command by going to the Settings app and selecting Siri. Here we will be able to see the new shortcut created and create a voice phrase to invoke it.

Part 5: Donating an Interaction

The other way to make a donation is to use an INInteraction object. This involves a bit more work, but gives you more control over defining and handling the action.

Firstly we need to add our project and Siri Intent Definition File. You can do this by going to “File” -> “New” -> “File” and select Siri Intent Definition File. Make sure that you have selected as targets for this all the three targets of our project.

Select the newly created Intents.intentdefinition, here in the bottom left corner select the + icon and then New intent. Now that we have created an Intent let’s name it TestDrive, select the category as Book and add a Title and a Description. As well extra information for this intent can be added. You can add an Image to be shown for the intent and select the option.

Parameters can be defined for the intent to define the shortcut types. A shortcut type has title, subtitle and a set of parameter combinations such as:

  • car and duration
  • car and options
  • car, duration and options

These types define the schema that Siri uses to identify requests the user makes; for example, “Book a test drive for 60 minutes.” The parameter combination for this example is: car and duration.

Your final intent should look like this:

Now that we have created our intent, we must inform our Intent Extensions that our app will support this custom type.

Inside SiriIntentExtensionUI -> Info.plist select NSExtension expand NSExtensionAttributes expand IntentsSupported and add a new item of type String with the value TestDriveIntent. As well inside SiriIntentExtension -> Info.plist and repeat the previous step.

Now that we have configured our app and our extensions to know about the intent, we can now start focusing on handling it.

Handle the intent

Inside our SiriIntentExtension, select default created IntentHandler and add the following lines of code there:

override func handler(for intent: INIntent) -> Any {
guard intent is TestDriveIntent else {
    fatalError("Intent type not recognised \(intent)")
    }

    return TestDriveIntentHandler()
}

The above function will handle our custom intent. It will first check if the intent is one of our custom intents and then return a new instance of TestDriveIntentHandler. At this moment this will throw an error at compile time, since TestDriveIntentHandler is not yet defined, so lets go ahead and create it now.

Inside your main target create a new Swift file and name it TestDriveIntentHandler (a protocol generated for us from the Intents Definition File) . Select all 3 Target memberships for it (this way our extensions and our app can use the same custom handler) and let’s start coding!

Go ahead and create TestDriveIntentHandler as a class conforming to NSObject and TestDriveIntentHandling. Then add the following functions public func confirm(intent: TestDriveIntent, completion: @escaping (TestDriveIntentResponse) -> Void) which will allow us to perform some final validation on the intent parameters and public func handle(intent: TestDriveIntent, completion: @escaping (TestDriveIntentResponse) -> Void) which will allow us to complete the booking.

Your final class should look like this:

import UIKit
import Intents

class TestDriveIntentHandler: NSObject, TestDriveIntentHandling {
    /// - Tag: confirm_intent
    public func confirm(intent: TestDriveIntent, completion: @escaping (TestDriveIntentResponse) -> Void) {

    /*
    The confirm phase provides an opportunity for you to perform any final validation of the intent parameters and to
    verify that any needed services are available. You might confirm that you can communicate with your company’s server
    */
        guard let car = intent.car,
            let modelId = car.identifier,
            let _ = DataManager.shared.findCar(Int(modelId)!) else {
            completion(TestDriveIntentResponse(code: .failure, userActivity: nil))
            return
        }   

        // Once the intent is validated, indicate that the intent is ready to handle.
        completion(TestDriveIntentResponse(code: .ready, userActivity: nil))
    }

    public func handle(intent: TestDriveIntent, completion: @escaping (TestDriveIntentResponse) -> Void) {

        guard
            let order = TestDrive(from: intent)
            else {
                completion(TestDriveIntentResponse(code: .failure, userActivity: nil))
                return
        }

        //  The handle method is also an appropriate place to handle payment via Apple Pay.
        //  A declined payment is another example of a failure case that could take advantage of a custom response.

        TestDriveManager.shared.bookTestDrive(order.car, duration: order.duration)

        //  For the success case, we want to indicate a wait time to the user so that they know when their booking order will be ready.
        //  Ths sample uses a hardcoded value, but your implementation could use a time interval returned by your server.
        completion(TestDriveIntentResponse(code: .success, userActivity: nil))
    }
}

Donating the intent

Now that our app knows how to handle the intent, we are now ready to donate it to Siri, when the user takes the specific action. Siri will then suggest interactions to the user based on how often the user performs the actions and the user will later be able to access the shortcut himself through the shortcuts app or from Siri Settings.

In our TestDriveManager go ahead and declare private func donateInteraction(for testDrive: TestDrive). We will use this function to donate the Interaction and we will call it before the return of our bookTestDrive function.

In order to donate the interaction we need to be able to generate an INInteraction from our TestDrive. To do so we must create a variable called intent in our TestDrive class.

Go ahead and add the following to our TestDrive.swift:

extension TestDrive {
    public var intent: TestDriveIntent {
        let intent = TestDriveIntent()
        intent.car = INObject(identifier: car.modelId.description, display: car.brand + " " + car.modelName)
        intent.duration = duration as NSNumber

        let options: [String: String] = ["fabricationYear": car.fabricationYear.description]
        var intentOptions: [INObject] = []
        options.keys.forEach { (key) in
            let value = options[key]
            intentOptions.append(INObject(identifier: key, display: value!))
        }

        intent.options = intentOptions
        intent.suggestedInvocationPhrase = "Book a test drive for \(car.brand) \(car.modelName)"

        return intent
    }

    public init?(from intent: TestDriveIntent) {
        guard let modelId = intent.car?.identifier,
            let car = DataManager.shared.findCar(Int(modelId)!),
            let duration = intent.duration
            else { return nil }

        self.init(car: car, duration: Int(truncating: duration))
    }
}

Our public var intent: TestDriveIntent is for us a computed property that will create a new intent of previously declared type public var intent: TestDriveIntent based on the information existing already in the TestDrive object. As well we have created a custom init for our class, so that we can convert the received TestDriveIntent to our TestDrive object at a later point.

Every time the user will book a test drive, we will donate an INInteraction to Siri so that she can suggest it to our user.

Now so that we can call our shortcut, we need to go to Settings -> Siri & Search. Here you will be able to find the donated shortcuts at the top of the view. When you tap our donated shortcut, Siri will invite you to create a custom phrase for it, suggesting as a reference point, our intent’s suggestedInvocationPhrase. Record a custom phrase and voila, Siri is now ready for you.

Go ahead and ask Siri to book a test drive for you. This is how it should look like when you ask Siri to book a test drive for you.

Something doesn’t look quite right here, and that is because our SiriIntentExtensionUI does not know to display anything to the user at the moment. For that to change we need to edit IntentViewController’s func configureView(for parameters: Set<INParameter>, of interaction: INInteraction, interactiveBehavior: INUIInteractiveBehavior, context: INUIHostedViewContext, completion: @escaping (Bool, Set<INParameter>, CGSize) -> Void) that will prepare the interaction to handle and display the corresponding UI.

In the SiriIntentExtensionUI’s MainInterface.storyboard you should be able to find TestDriveOverview and TestDriveBookingCompleted, two custom views I have already created and added for you to speed up the integration process. We will need to add these custom views as well in TestDriveBookingCompleted.

Now in IntentViewController add the following functions that will help us display the UI.

/// - Returns: Desired size of the view
private func displayOverview(for testDrive: TestDrive, from intent: TestDriveIntent) -> CGSize {
    overviewView.configure(testDrive)

    view.addSubview(overviewView)

    let width = self.extensionContext?.hostedViewMaximumAllowedSize.width ?? 320
    let frame = CGRect(origin: .zero, size: CGSize(width: width, height: 100))
    overviewView.frame = frame

    return frame.size
}

/// - Returns: Desired size of the view
private func displayOrderConfirmation(for testDrive: TestDrive, from intent: TestDriveIntent, with response:TestDriveIntentResponse) -> CGSize {
    completedView.configure(testDrive)

    view.addSubview(completedView)

    let width = self.extensionContext?.hostedViewMaximumAllowedSize.width ?? 320
    let frame = CGRect(origin: .zero, size: CGSize(width: width, height: 150))
    completedView.frame = frame

    return frame.size
}

We are now ready do display our custom UI for the interaction. Inside our configureView function add the following switch just before the completion call. This will check the status of our intent and display the matching UI.

// Different UIs can be displayed depending if the intent is in the confirmation phase or the handle phase.
var desiredSize = CGSize.zero
switch interaction.intentHandlingStatus {
    case .ready:
        desiredSize = displayOverview(for: testDrive, from: intent)
    case .success:
        if let response = interaction.intentResponse as? TestDriveIntentResponse {
            desiredSize = displayOrderConfirmation(for: testDrive, from: intent, with: response)
        }
    default:
        break
}

Bring Siri up on screen again and call your custom phrase again. Now you should be able to see the custom UI which looks something like this:

The last thing for us to do before we have completely integrated shortcuts into our app is to handle the intent inside our app as well.

Inside your AppDelegate.swift, go ahead and edit the func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool as follows:

func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    if let intent = userActivity.interaction?.intent as? TestDriveIntent {
    //handle intent
        handle(intent)
        return true
    } else if userActivity.activityType == NSUserActivity.ActivityTypes.catalog {
    //handle activity
        return true
    }
    return false
}

private func handle(_ intent: TestDriveIntent) {
    let handler = TestDriveIntentHandler()
    handler.handle(intent: intent) { (response) in

    if response.code != .success {
        print("failed handling intent")
    } else {
        guard let window = self.window,
            let rootViewController = window.rootViewController as? UINavigationController,
            let vc = rootViewController.viewControllers.first as? ViewController else {
                return
            }
        let storyboard = UIStoryboard(name: "Main", bundle: .main)
        let confirmationVC = storyboard.instantiateViewController(withIdentifier: "BookingConfirmationViewController") as! BookingConfirmationViewController
        confirmationVC.testDrive = TestDrive(from: intent)
        vc.navigationController?.pushViewController(confirmationVC, animated: true)
        }
    }
}

We have added the handle function that will present to our user BookingConfirmationViewController if we have received a successful intent of type TestDriveIntentHandler.

Part 6: Add Phrases to Siri

Another feature to add to our app, is the option to add phrases to Siri directly from our app. This will make the experience for the user a better one.

For this to happen we will need to create a VoiceShortcutsManager that will have as purpose to update and add voice shortcuts to our intents.

import Foundation
import Intents

public class VoiceShortcutsManager {

    private var voiceShortcuts: [INVoiceShortcut] = []

    public init() {
        updateVoiceShortcuts(completion: nil)
    }

    public func voiceShortcut(for order: TestDrive) -> INVoiceShortcut? {
        let voiceShorcut = voiceShortcuts.first { (voiceShortcut) -> Bool in
            guard let intent = voiceShortcut.__shortcut.intent as? TestDriveIntent,
                let testDrive = TestDrive(from: intent) else {
                return false
            }   
            return order == testDrive
        }
        return voiceShorcut
    }

    public func updateVoiceShortcuts(completion: (() -> Void)?) {
        INVoiceShortcutCenter.shared.getAllVoiceShortcuts { (voiceShortcutsFromCenter, error) in
        guard let voiceShortcutsFromCenter = voiceShortcutsFromCenter else {
            if let error = error {
                print("Failed to fetch voice shortcuts with error: \(error.localizedDescription)")
            }
            return
        }
            self.voiceShortcuts = voiceShortcutsFromCenter
            if let completion = completion {
                completion()
            }
        }
    }
}

In our CatalogViewController go ahead and declare a private lazy var voiceShortcutManager = VoiceShortcutsManager.init(). Now let’s allow the user set or edit a voice shortcut everytime it clicks on one of our catalog items. This is not an amazing UX but will make it easier for us to play.

In didSelectRowAt go ahead and add the following block:

if let shortcut = voiceShortcutManager.voiceShortcut(for: testDrive) {
    let editVoiceShortcutViewController = INUIEditVoiceShortcutViewController(voiceShortcut: shortcut)
    editVoiceShortcutViewController.delegate = self
    present(editVoiceShortcutViewController, animated: true, completion: nil)
} else if let shortcut = INShortcut(intent: testDrive.intent) {
    let addVoiceShortcutVC = INUIAddVoiceShortcutViewController(shortcut: shortcut)
    addVoiceShortcutVC.delegate = self
    present(addVoiceShortcutVC, animated: true, completion: nil)
}

Here we check if the voiceShortcutManager knows of a shortcut for our intent and allow the user to create or update the shortcut. As well we need to conform to the delegate methods for INUIAddVoiceShortcutViewControllerDelegate and INUIEditVoiceShortcutViewControllerDelegate, by adding the following:

func updateVoiceShortcuts() {
    voiceShortcutManager.updateVoiceShortcuts(completion: nil)
    dismiss(animated: true, completion: nil)
}
// MARK: - INUIAddVoiceShortcutViewControllerDelegate

extension ViewController: INUIAddVoiceShortcutViewControllerDelegate {

    func addVoiceShortcutViewController(_ controller: INUIAddVoiceShortcutViewController,
    didFinishWith voiceShortcut: INVoiceShortcut?,
    error: Error?) {
        if let error = error {
            print("error adding voice shortcut:\(error.localizedDescription)")
            return
        }
        updateVoiceShortcuts()
    }

    func addVoiceShortcutViewControllerDidCancel(_ controller: INUIAddVoiceShortcutViewController) {
        dismiss(animated: true, completion: nil)
    }
}

// MARK: - INUIEditVoiceShortcutViewControllerDelegate

extension ViewController: INUIEditVoiceShortcutViewControllerDelegate {

    func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController,
    didUpdate voiceShortcut: INVoiceShortcut?,
    error: Error?) {
        if let error = error {
            print("error adding voice shortcut:\(error.localizedDescription)")
            return
        }
        updateVoiceShortcuts()
    }

    func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController,
    didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID) {
        updateVoiceShortcuts()
    }

    func editVoiceShortcutViewControllerDidCancel(_ controller: INUIEditVoiceShortcutViewController) {
        dismiss(animated: true, completion: nil)
    }
}

Here the updateVoiceShortcuts will trigger a re-sync of the shortcuts in the system and dismiss INUIEditVoiceShortcutViewController or INUIAddVoiceShortcutViewController.

Final notes

You have now made it all the way to the end of this post, by the end of which, hopefully you will have a clear picture of how to implement and handle interaction to the Google Cast SDK.

Don’t forget to download our final project and compare our results. :)

Final Project

Hope to see you next time!

Article Photo by Alvaro Reyes

ios siri siri shortcuts test drive

Author

Andrei Hogea

iOS Developer

iOS developer based in Copenhagen, Denmark. Always curious

You may also like

Automate Debugging and Testing Workflows using ADB

While developing and testing we usually come across repetitive tasks involving manual efforts. Navigating to a certain part of the app, filling out sign up forms or simply taking screenshots are all time-consuming tasks. In this blog post we look at how we can utilize ADB (Android Debug Bridge) to...

Android
Is Flutter ready for production?

After hearing about Flutter from different sources and developers with all kinds of background we decided to try to rebuild an existing Android app in Flutter and see the the state of the Mobile framework.

iOS Android