How I added a Siri Shortcut to an existing app in under an hour* using the open source framework Flint

Appx. reading time: 10min

Those few of you that follow what I’m doing will know that the open source Flint framework I started is nearing its full public 1.0 release. My goal with this project is to help people develop high quality and robust apps on Apple platforms in less time, with deeper OS integration with minimal effort.

It works by having a lightweight action dispatch pattern that does not force any other architecture change on your code, and through this makes it easy and consistent to deal with a wide range of work we have to do in apps on Apple platforms; URL handling and deep linking, Handoff & Siri prediction via smart NSUserActivity handling, Shortcuts, In-app purchases, permissions, analytics, feature-flagging and logging.

Approaching the final Flint 1.0 is exciting for obvious reasons, but it also means work can resume in earnest on our awesome forthcoming app built using it. If you have big company or team information screens in your offices you’ll definitely want to check out Bloop.

With most of my time taken up by client work on the excellent design app Concepts, even now the pace of development on Bloop will be a little slow.

With that in mind, and with the desire to demonstrate the elegance and power of Flint, I decided to challenge myself to spend a very short amount of time getting our old non-Flint based “insult generator” app Hobson up and running again while adding a Siri Shortcut using Flint that will let you say “Hey Siri, insult me”. Some people will find it funny, some won’t but it serves as a great real-world example.

Hobson was previously removed from sale because of the great 32-bit app purge, as we simply didn’t have time to bring it up to date. The app is relatively simple and this made it a great candidate for this exercise.

This article is not intended as a top-to-bottom guide for creating Siri Shortcuts. There are plenty of those out there, and there are typically a lot of moving parts. Here I will show how to do this with Flint as the way of performing your shortcut actions, and how easy it is to retrofit it into existing apps. All the code fragments shown are directly pasted from the actual app source that is shipping right now in the App Store.

So with my family watching an episode of Stranger Things S2 in the other room, I set out to add a Siri Shortcut to “insult me” using Flint before the Stranger Things episode ended. It’s a fun, quick little project that should be informative for those interested in either Flint or Siri Shortcuts.

Here’s how it went and what it involves, warts and all.

Start time 8:38 PM

I’m very big on “dog-fooding” so I fired up the Flint Getting Started documentation to follow everything as a developer new to Flint would. I’d make notes as I went about any deficiencies in the documentation and update it later.

The app project had not been touched for some time and needed some updating for latest Xcode and Swift. It did not previously use Carthage. I’d already thrown out all the Cocoapods dependencies however, so I started fresh with Carthage.

Setting up the Flint dependency worked fine:

  • I created the Cartfile and added github "MontanaFlossCo/Flint" "ea-1.0.7" to it
  • I ran carthage bootstrap —platform ios
  • I then added the Carthage/Builds/iOS/FlintCore.framework framework that Carthage had built to the main app target
  • I added the carthage copy-frameworks script to the Build Phases of the main target and the FlintCore file to its inputs and outputs
  • I also added the FlintCore framework that Carthage had built to the “Linked libraries” of the common HobsonCore framework target

While doing this I noticed that the Flint docs linked out to a Carthage guide for setting up frameworks on your target, so I’ll add some of this information directly to the Flint docs to make this easier for people new to Carthage.

At this point the app compiles and links against FlintCore but no code changes have been made yet. We’re ready to get started.

Bootstrapping Flint in the app

Next up, I had to bootstrap Flint in the app and make sure it all ran fine without any other changes, so I followed the Creating your Features and Actions guide.

First I needed to create the feature that will represent the shortcut, with no implementation yet:

import Foundation
import FlintCore

public final class InsultShortcutsFeature: Feature {
    public static var description: String = "Insult Shortcuts"
    
    public static func prepare(actions: FeatureActionsBuilder) {
    }
}

I created a Swift class AppFeatures in the Hobson app target to group all the high-level features of the App:

import Foundation
import FlintCore
import HobsonCore

public final class AppFeatures: FeatureGroup {
    public static var description = "Hobson main features"
    
    public static var subfeatures: [FeatureDefinition.Type] = [
        InsultShortcutsFeature.self
    ]
}

These static properties are conventions. People familiar with convention-over-configuration frameworks like Rails or Grails will be familiar with this idea, which Flint uses extensively but using the power of Swift protocol extensions rather than dynamic metaprogramming. Essentially Flint uses protocols like FeatureGroup that require properties and functions that provide the information Flint needs to do smart things with your features and actions.

Next I added a call to Flint.quickSetup(AppFeatures.self) in the AppDelegate:

public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
    Flint.quickSetup(AppFeatures.self,
        initialDebugLogLevel: .debug,
        initialProductionLogLevel: .off)
    return true
}

I built and ran the app. Everything was fine.

Except… I didn’t see any of the default Flint logging output. After a bit of head scratching later on in the evening when nothing was working in Flint, I realised that the AppDelegate had an old and incorrect method signature for applicationDidFinishLaunching so it was never being run and had not been auto-migrated by Xcode. The price of Swift progress 🤷‍♂️

Creating the action that will perform the new Siri Shortcut

There’s a fair few moving parts in getting shortcuts running. I opened up the new Flint Siri Shortcuts guide to follow the steps there, to see if there were any gaps and frankly because I’d forgotten all the details of doing it from scratch.

I created a new Intent Extension target in the project, called HobsonIntents.

I then created an intent definition file with an intent and response (you’ll also see here the other intents I added later before shipping):

Now the Flint part comes in. We need to add an Action that implements the intent, and bind that to the InsultShortcutFeature type I created earlier.

So I added an InsultMeShortcutAction action that extends IntentAction (which automatically sets up an appropriate queue for background intents — a specialisation of the Flint Action type), and put this in my common code HobsonCore framework target, so that the app and the intent extension can find it:

import Foundation
import FlintCore
import Intents

public final class InsultMeShortcutAction: IntentAction {
    public typealias InputType = NoInput
    public typealias IntentType = InsultMeIntent
    public typealias IntentResponseType = InsultMeIntentResponse
    
    public static let suggestedInvocationPhrase: String? = "Insult me"
    
    public static func intent(withInput input: InputType) -> IntentType? {
        let intent = InsultMeIntent()
        return intent
    }
    
    public static func input(withIntent intent: InsultMeIntent) -> InputType? {
        return NoInput.none
    }
    
    public static func perform(context: ActionContext<NoInput>, presenter: InsultMeShortcutAction.PresenterType, completion: Completion) -> Completion.Status {
        let phrase = FilthStore.filthStore.next()

        let response = InsultMeIntentResponse.success(insult: phrase.text.lowercased())

        presenter.showResponse(response)

        return completion.completedSync(.success)
    }
}

This artefact is the heart of the power of Flint. We have a consistent action dispatch mechanism that is thread safe, queue-aware and observable — allowing Flint to do all kinds of automatic things for us while nicely decoupling our code and removing these concerns from the call sites in our app code. We define the input type for the action, and in this case because it is an Intent, we also specify the type of the intent and response — the types automatically generated from the intent definition file. For more details on implementing Flint actions see the guide. The Intent-specific details are covered in the Shortcuts guide.

In the perform function we actually do the work the intent requires. Flint automatically wraps up the Intent handler’s completion callback in a presenter, because all Flint actions take a single InputType and a single PresenterType. The function finishes up by returning a completion status to Flint so that it knows the outcome of the action.

⚠️ Note that the Xcode fix-it for adding stubs to conform to IntentAction let me down. It made the presenter argument of perform of type PresenterType which is correct — this is an associated type defined in IntentAction. However the compiler complains the action does not conform to Action in that case. That is why I had to fully namespace the type of the argument as InsultMeShortcutAction.PresenterType which is unfortunate. There are some ongoing niggles with “overriding” default associated types in protocol extensions in Swift that I’m still trying to work through on the Swift forums.

With this code in place, I found I couldn’t reference the InsultMeAction shortcut types, because of an Xcode configuration issue with the code generation and targets. It turns out Xcode had used the wrong name (InsultMeIntentIntent) for the generated types, and the place to fix this is hidden on a tab in the attributes inspector of the intent definition file. You also have to indicate which targets should include the generated classes in the file property inspector of the intent definition file.

Intent definition file attributes

Intent definition file target settings
Next up I had a bunch of bundle ID naming issues on the extensions related to the prefix not matching with the app ID in the various build configurations and targets we have. This carried on the next day when I revisited this — much pain!

Finally we need to bind and declare the action in the feature:

import Foundation
import FlintCore

public final class InsultShortcutsFeature: Feature {
    public static var description: String = "Insult Shortcuts"
    
    public static let insultMe = action(InsultMeShortcutAction.self)

    public static func prepare(actions: FeatureActionsBuilder) {
        actions.declare(insultMe)
    }
}

This addition of insultMe using the Flint action function to bind it, and then using declare() in the feature’s prepare function, tells Flint what the user can “do” with this feature. If it was a “conditional feature” it would also mean that none of these actions could be performed in code without checking if the feature is available first.

Administrative stuff

I had to update the project deployment target to 12.0 to support Shortcuts. No big deal. The small audience of Hobson is not going to cry about this, and if they do, I can have Hobson send them an insult as a support response. He’s great at customer support.

Implementing the Intent Extension

I’d already written the Flint action that does the work of the “insult me” intent. Now I just needed to plumb in the Intent Extension parts to bootstrap Flint and perform the action.

In the IntentHandler that Xcode created for the target:

import Intents
import FlintCore
import HobsonCore

var staticInitialisationDone = false

class IntentHandler: INExtension{
    override init() {
        super.init()
        
        if !staticInitialisationDone {
            Flint.quickSetup(IntentFeatures.self, initialDebugLogLevel: .debug)
            staticInitialisationDone = true
        }
    }
    
    override func handler(for intent: INIntent) -> Any {
        switch intent {
            case is InsultMeIntent: return InsultMeIntentHandler()
            default: fatalError("Unknown intent type: \(intent)")
        }
    }
}

This bootstraps Flint within the intent extension, using a separate top-level feature group — functionally the same as the App feature group in this case, but I know from experience that usually you want a smaller subset of your features declared in app extensions:

import Foundation
import FlintCore
import HobsonCore

public final class IntentFeatures: FeatureGroup {
    public static var description = "Hobson intent features"
    
    public static var subfeatures: [FeatureDefinition.Type] = [
        InsultShortcutsFeature.self
    ]
}

This references the shortcut feature that was defined in the common code framework I have called HobsonCore.

Finally we add code to perform the insultMe Flint action we defined earlier on IntentShortcutsFeature:

import Foundation
import Intents
import FlintCore
import HobsonCore

@objc
class InsultMeIntentHandler: NSObject, InsultMeIntentHandling {
    @objc(handleInsultMe:completion:)
    func handle(intent: InsultMeIntent, completion: @escaping (InsultMeIntentResponse) -> Void) {
        let outcome = InsultShortcutFeature.insultMe.perform(withIntent: intent,
                                                             completion: completion)
        assert(outcome == .success, "Intent failed: \(outcome)")
    }
}

This performs the action on the feature I created, passing in the intent and completion. Flint does the rest!

😿 Compilation failed, and I’d forgotten to add FlintCore to the linked frameworks of the Intent Extension target. Easily solved by dragging and dropping Carthage/Builds/iOS/FlintCore.framework onto the list of frameworks on that target.

We’re up and running… or are we?

By this point I’m about 45 minutes in and all the Flint and code-related work is done 🎉! I consider this a success in terms of getting the work done in the time window.

…and yet the shortcut is nowhere to be found. Aha! There’s no donation of it happening, so Siri “doesn’t know anything” about it.

At this early stage I didn’t want to build a UI for the user to add voice shortcuts to Siri, but Siri needed to know it exists as something the user can set up in “Settings > Siri & Search”. The way to do this is to donate shortcuts to the system.

In Hobson’s UI code where the tap is handled before it shows the next insult, I added a call to Flint’s donateToSiri(withInput:) function that is available on intent action bindings:

func userRequestedNewPhrase() {
    InsultShortcutFeature.insultMe.donateToSiri(withInput: .none)
    …
}

At runtime this was choking because I hadn’t added the intent type name to the NSUserActivityTypes key in the app’s Info.plist. This seems an odd requirement for donating shortcuts because we’re implementing a background intent extension, but it seems Siri Shortcuts must always be able to open your app to perform the same action. I bet a lot of people don’t implement that — in my case Hobson always shows you an insult anyway when you open so… that’s good enough for me.

With InsultMeIntent added to NSUserActivityTypes, I ran it again. There was another problem that prevented donation or intent execution but sadly I didn’t make a note of the actual error. The fix was that I hadn’t included my intent definition file in the main app target. It has to be present in both the Intent Extension and the main app targets.

Running again, I could see the Hobson shortcut in ”Settings > Siri & Search > All Shortcuts” and also on the search screen as I had turned on the shortcut donation debug options in Settings.

When I launched the shortcut, I hit an exception because my shared code could not access settings or data shared with the app. I’d forgotten to add the shared App Group ID to the Intent Extension. Easily fixed.

A weird roadblock. Flint is asserting saying the input to the Intent is nil

This actually took me a long time to diagnose. When running the shortcut, Flint was choking while trying to create an input for the intent action. When an intent action is performed, Flint will ask the IntentAction type itself to create an instance of its input type from the INIntent, by calling input(withIntent:). This is the implementation I had at the time:

public static func input(withIntent intent: InsultMeIntent) -> InsultMeShortcutAction.InputType? {
    return nil
}

OK, so yeah I had returned nil because I hadn’t finished implementing it, and in this context nil means “it was not possible to create an input from the INIntent” and that is indeed an error that Flint is telling me about.

So I fixed it, because what I meant to return was the none property of NoInput:

public static func input(withIntent intent: InsultMeIntent) -> InsultMeShortcutAction.InputType? {
    return .none
}

Easy, right? It still didn’t work, and Flint was still telling me the input was nil. I thought I was going mad. Eventually I remembered something I had read recently about issues with the priority of resolving .none where Optional types are involved. I can’t remember what or where that was, but it was enough to make me realise my mistake. The return type of that function is InputType?, AKA Optional so type inference was resolving .none to Optional.none.

The fix was annoying but simple:

public static func input(withIntent intent: InsultMeIntent) -> InputType? {
    return NoInput.none
}

UPDATE: Now that Flint 1.0 is released, the rest of this article is updated to reflect a new enum case for NoInput to avoid this confusion in future. NoInput.noInput is now unambiguous in this context.

Success!

It was a great moment when it finally came together. Hearing Siri actually speak the responses was fun. Something for Craig Federighi to roll his eyes at.

Siri speaking a Hobson response

There was a lot of wrangling with the nitty gritty of project and target configuration for shortcuts but aside from that, the fact that Hobson was already modularised into an app plus HobsonCore framework for the shared parts (we already had a Today widget and WatchKit 1 extension) made the implementation of the shortcut intent itself trivial.

I noticed however that the suggested invocation phrase for the shortcut was not being set. In Flint we have a suggestedInvocationPhrase property convention on actions already, for use when auto-registering NSUserActivities with the Activities feature of Flint. However the donateToSiri code was not using this when creating the INShortcut to donate. I added this to Flint on master since, but at the time I simply set this in the action’s intent(withInput:) implementation. This convention pattern was deliberately chosen when building Flint to provide sensible default behaviours but still allow full customisation.

// Inside InsultMeAction
public static func intent(withInput input: InputType) -> IntentType? {
    let intent = InsultMeIntent()
    // Workaround, no longer needed, to copy `suggestedVoicePhrase` from self
    intent.suggestedVoicePhrase = suggestedVoicePhrase
    return intent
}

End time 11:40 PM

By now my eldest daughter had joined me at the table, doing some of her school work (this is not usual at this time of night!).

Where I got to:

  • The app now builds with Flint and an Intent Extension
  • It donates shortcuts for getting an insult
  • After manually recording a phrase for it in system Settings, you can ask Siri for an insult on the iOS device, on your watch and also on HomePod and it will speak the response
  • You can add the shortcut to a Shortcut in the shortcuts app (shortcuts all the way down!) and add some sass to your workflows. Or not.

What did using Flint give us here?

This is a fair question. On the surface it looks like we just pushed a little code that would be in an Intent Extension into this new Action type and added some weird “Feature” types.

Here’s a quick rundown of why this is so powerful:

  • We have a standardised way to perform actions, whether from intents or other entry points (activities, URLs, app UI)
  • The ability to make these shortcut actions conditionally available based on other feature requirements such as permissions, purchases or feature flagging
  • A clean separation of action code from Intent extension and App code
  • Quick and simple Intent donation
  • Flint’s debug timeline of user interactions for these shortcuts is available
  • Flint’s Activity-based contextual logging tells us what the user is doing, and what the inputs and outcomes are
  • Most profoundly; thinking in terms of what our users do with our app, not how it has to integrate with the system at every point

This is not even factoring in that it is now trivial for me to start moving other Hobson functionality over to the Action pattern, to get automatic support for Siri prediction and search, and conditional features that require permissions checking and in-app purchases.

Shipping the app; adding more shortcuts, making shortcuts discoverable and adding the UI for adding and editing shortcuts

A few days later I got a chance to continue this work. I added a couple more shortcuts to return one of your favourite insults and one to pick a random one and place it on the clipboard for Shortcuts workflow integration — this was really easy now all the other Intent Extension issues had been worked out.

These shortcuts did not make sense to donate, so to make them visible in “Settings > Siri & Search” I had to use the INVoiceShortcutCenter.shared.setShortcutSuggestions API. It turns out Flint was missing a little API here to create an INShortcut for a given IntentAction so I added a shortcut(input:) function on all action bindings on Flint master, and used it in the app to pre-register all the shortcuts:

public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
    Flint.quickSetup(AppFeatures.self, initialDebugLogLevel: .debug, initialProductionLogLevel: .none)

    INVoiceShortcutCenter.shared.setShortcutSuggestions([
        InsultShortcutFeature.insultMe.shortcut(withInput: .noInput),
        InsultShortcutFeature.makeMeLaugh.shortcut(withInput: .noInput),
        InsultShortcutFeature.getInsult.shortcut(withInput: .noInput)
    ].compactMap({ $0 }))
    
    return true
}

With that done, I could use the new shortcuts, as in this case where I use the shortcut to copy the result to the clipboard so it can be pasted in Shortcut workflows:

Shortcuts app using the new shortcuts

Adding shortcut support is not enough however, you usually want a UI to allow users to add, update and remove the shortcuts because few users know about the Siri Shortcuts section of the system Settings app.

So I added a settings screen to Hobson where you can add or edit shortcuts.

Hobson Intent Settings UI

Flint ea-1.0.7 already had an addVoiceShortcut function on intent actions that would show the system UI for this, but it didn’t have one to edit them, so I added editVoiceShortcut and refactored the completion handlers on them slightly to make the API more elegant. Here’s an example of how Hobson handles this now:

func selectShortcutRow(_ index: Int) {
    let viewModel = shortcutsViewModels[index]
    switch viewModel.intentType {
        case is InsultMeIntent.Type:
            if let shortcut = viewModel.voiceShortcut {
                InsultShortcutFeature.insultMe.editVoiceShortcut(shortcut, presenter: self) { [weak self] result in
                    switch result {
                        case .deleted:
                            self?.shortcutDeleted(shortcut)
                        case .updated(let updatedShortcut):
                            self?.shortcutUpdated(updatedShortcut)
                        default:
                            break
                    }
                }
            } else {
                InsultShortcutFeature.insultMe.addVoiceShortcut(withInput: .none, presenter: self) { [weak self] result in
                    switch result {
                        case .added(let addedShortcut):
                            self?.shortcutAdded(addedShortcut)
                        default:
                            break
                    }
                }
            }
            …
}

The use of static typing bites me a little here, as I have to essentially duplicate that code for each shortcut type, because each action binding type is discrete (Flint creates a StaticActionBinding<FeatureType, ActionType> for each binding, and all the “magic” Flint functions are extensions on this). I could at least factor out the completion handling, and maybe do something more fancy in future.

However this static typing of actions is a huge part of the power of Flint and Swift, providing a welcome lack of runtime surprise when invoking actions.

With all this done, the app was sent off for review and is available for free in the App Store now.

Join us on the Flint journey

Thanks for reading this long but hopefully interesting post.

If you find the ideas behind Flint interesting we’d love to hear from you, and we’d love more contributors. We’ll be releasing the full 1.0 before WWDC 2019, and after that we’ll begin working on features for 1.1.

Please do check out Concepts the design drawing app I work on as my day job. Also do sign up for updates on Bloop, my next app that is not in any way insulting, and will transform how you use large information screens in your work environment.

Thanks to @johnsundell for his Splash syntax highlighter, which I’ve given a spin in this post. I used SplashMarkdown to take my markdown input and replace the code fragments with highlighted HTML rather than creating gists for every fragment as I have in the past.

UPDATED 23 MAY 2019: Code updated to match the now full release of Flint 1.0

*: It really does take under an hour to do all the shortcut and Flint code stuff. The rest was provisioning and project config hell.

About

Marc Palmer (@marcpalmerdev) is a consultant and software engineer specialising in Apple platforms. He created the Flint open source framework. He writes native apps like the music practice app Soundproof for iOS devices for his company Montana Floss Co.. He can also do a pretty good job of designing products. Don't ask him to draw anything, because that's just embarrassing. You can find out more about him here.

Comments are closed.