Categories
iOS SiriKit

Demystifying Siri, Part 5: Intents Extensions

After a somewhat circuitous route we are now finally ready to implement our interactive voice-based interface to NumberRace. At the end of this part, Siri will ask us for a target number and six initial numbers and read out a solution.

Mea culpa

First, though, a confession. Is this really the best use of a voice interface? According to Apple’s Human Interface Guidelines, Siri Shortcuts should be used to accelerate common actions. The guidelines suggest designing intents that require as few follow-up questions as possible. We have seven parameters to complete before Siri is able to provide a solution. Are we really saving a user time by providing a voice-based interface with seven questions to answer? Well, in my defence, we could theoretically run our shortcut on a HomePod or Apple Watch so we’re making our solver available on more devices. We’ll press on and see how cumbersome our shortcut is in action.

Intents Extensions

Our app interacts with Siri through an Intents extension. This extension does the job of retrieving the responses from Siri, identifying missing parameters, and supplying the results of our intent.

We’ll be adding a new Intents extension target to our project shortly, but first there are a few things we need to do before we get coding. Firstly, we’ll create an App Group so that the app and the Intents extension can share resources between them. Use the Apple Developer Portal to create an App Group and assign it to one or more App IDs. Add the App Group to your project target by navigating to the Signing & Capabilities section of your target’s settings in Xcode.

Secondly, you’ll need to ensure that the Siri capability is enabled. While you’re in the Signing & Capabilities section, click on the + Capability button and select Siri from the list.

The + Capability button in Xcode
The + Capability button in Xcode

In my NumberRace app, I have a Solver class that does all the calculations needed to generate a solution. It’d be great if this code could be shared by both my app and the Intents extension to save space. I’ve therefore moved this class into a framework to save having to include it in both targets.

I won’t trouble you with the steps involved in moving your code into a framework as there are plenty of tutorials online. I used this one from Ray Wenderlich.

A new target

Next, go to File > New > Target… and select Intents Extension. Then click Next.

The New target dialog box in Xcode with Intents Extension selected
Intents extension

Let’s call our extension NumberRaceIntents and we’ll check the Include UI Extension box while we’re at it.

Next, add our App Group to the NumberRaceIntents and NumberRaceIntentsUI targets.

You’ll recall that in Part 3 we spoke about our .intentdefinition file being translated into custom generated classes. We’ll need to ensure that our new extensions have access to these classes. Click on Intents.intentdefinition and, in the right hand pane under Target Membership, ensure that our new extensions are checked and that Public Intent Classes is selected for each.

The Target Membership pane of Intents.intentdefinition, with Public Intent Classes selected for our three targets

Finally, we need to specify the list of intents that our extension will handle. Go to the Target settings, and in the General section, add SolveGameIntent to the list of supported intents.

The Supported Intents section of our Intents target settings.
The Supported Intents

Writing some code

Returning to our Intents extension, a default IntentHandler.swift has already been created. In this default handler, we’ll check to see if our SolveGame intent has been invoked and use a Solve Game-specific handler instead to perform all the tasks we need.We’ll create that specific handler in a moment, but for now, let’s change our intent handler code to the following:

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.
        guard intent is SolveGameIntent else {
            fatalError("Unhandled intent type: \(intent)")
        }
        return SolveGameIntentHandler()
    }
}

Our handler will react to requests from Siri when it needs assistance in handling our intent. This means:

  • Resolving intent data – letting Siri know that data is missing or invalid so that Siri can ask the user for clarification
  • Handling the request once all data has been resolved, passing the results back to Siri for reading out

Let’s start with the first of those items – resolving data.

Resolving data

Let’s create a new file in our Intents target, SolveGameIntentHandler.swift. We’ll start off by resolving our initial numbers. Add the following code to our file:

import Foundation
import Solver

class SolveGameIntentHandler: NSObject, SolveGameIntentHandling {
    func resolveInitialNumber(_ initialNumber: InitialNumber) -> InitialNumberResolutionResult {
        if initialNumber.rawValue != 0 {
            print(initialNumber.rawValue)
            return InitialNumberResolutionResult.success(with: InitialNumber(rawValue: initialNumber.rawValue)!)
        } else {
            return InitialNumberResolutionResult.needsValue()
        }
    }

    func resolveNumber1(for intent: SolveGameIntent, with completion: @escaping (InitialNumberResolutionResult) -> Void) {
        completion(resolveInitialNumber(intent.number1))
    }
}

Here we’ve written a function to resolve an initial number, resolveInitialNumber(), and we’re using it to resolve our first initial number, number1. If we have a valid initial number then the rawValue of our InitialNumber enum is greater than 0 and we can send a success result to Siri’s completion handler. All is well. If the rawValue is 0, then Siri wasn’t able to match our spoken input to one of the enum values, and so we send a needsValue result; this tells Siri to ask the user again for a number.

We can resolve numbers 2-6 in the same way, so I won’t trouble you with the duplication.

Resolving the target is a similar process:

    func resolveTarget(for intent: SolveGameIntent, with completion: @escaping (SolveGameTargetResolutionResult) -> Void) {
        if let target = intent.target {
            completion(SolveGameTargetResolutionResult.success(with: Int(truncating: target)))
        } else {
            completion(SolveGameTargetResolutionResult.needsValue())
        }
    }
            

If the target exists then we can send the success result. Otherwise our intent needs a value and Siri will ask one more time.

Handling data

We have all our data, and it’s valid – what now? The next stage is to handle the data, perform the calculation, and pass the result, in the form of one or more parameters, to Siri. First, though, we need to define those result parameters. In our app’s target let’s reopen Intents.intentdefinition. Click on the Response item on the left. We’ll add two strings as our response properties –

  • howManyAway which contains a description of how close the solver came to solving the game, and
  • spokenResult which describes the solution.

If the solving process was successful we can add those parameters to our success response template, as per the screenshot below.

The solve game intent response, showing result parameters and the spoken phrase on success.
Our Solve Game intent response.

Let’s make use of those parameters now. Let’s open our Solve Game intent handler and add the following piece of code. It’s not an elegant piece of code, but hey.

  func handle(intent: SolveGameIntent, completion: @escaping (SolveGameIntentResponse) -> Void) {
        let (closest, result) = Solver.helper.getResult(numbers: [
            intent.number1.rawValue,
            intent.number2.rawValue,
            intent.number3.rawValue,
            intent.number4.rawValue,
            intent.number5.rawValue,
            intent.number6.rawValue
        ], target: Int(truncating: intent.target!))

        let howManyAway = closest == 0 ? "I've found a solution" : "I could only find a solution \(closest) away" 
        completion(SolveGameIntentResponse.success(howManyAway: howManyAway, spokenResult: result))
  }

The principle is the same as before – sending a success result to Siri’s completion handler when the request is handled. Here we have a Solver class that does all the work in generating a solution, and we pass the howManyAway and spokenResult parameters to Siri in our success result.

Lost in translation

Let’s try it out!

Siri asks for our first number – let’s say 3:

A Siri conversation where a number is selected and a new number is requested
Our first number is recognised…

So far so good. Siri is now asking for a second number – let’s say 8:

A Siri conversation where some input has been given but the same number is requested
…but our second number is not

Eh? Siri is asking us for the second number again. This is a problem – what is going on here and how do we fix it? In Part 6 we’ll journey into the dark heart of Apple’s documentation and attempt to bridge the gaps we find therein.