Build Siri experiences across apps, Part 2: Content Transfer

⋅ 5 min read ⋅ iOS Siri App Intents

Table of Contents

In Part 1: Onscreen Awareness, we connected our views to App Entities so Siri could understand which content someone was referring to.

The next step is making that content usable by another app. For example, someone might ask Siri to send the photo they're looking at through a messaging app.

In this part, we'll explore content transfer, which allows other apps to act on our entity. It has two sides:

  • Exporting content: Let other apps use content from our app.
  • Importing content: Let our app receive and process content from other apps.

Exporting content to another app

Whatever our entity represents, exporting takes the same two steps:

  1. Expose our content as a system type that other apps understand, using a property on our entity.
  2. Adopt Transferable and return that property as an IntentValueRepresentation from its transferRepresentation requirement.

Say our app has a ContactEntity with its own properties. Step 1 adds a person property that describes the same contact as an IntentPerson, the system type for a contact:

struct ContactEntity: AppEntity {
var id: UUID
var name: String
var phoneNumber: String

var person: IntentPerson {
// Describe this contact as an IntentPerson,
// using its name and phone number.
}
}

Step 2 hands that property to the system:

extension ContactEntity: Transferable {
static var transferRepresentation: some TransferRepresentation {
IntentValueRepresentation(exporting: \.person)
}
}

Transferable, transferRepresentation, and IntentValueRepresentation are the parts the system defines.

ContactEntity, name, phoneNumber, and person are ours, and exporting: \.person tells the system which value to hand over. For another kind of content, we swap in our own entity and the system type that matches it.

Transferable has one requirement, transferRepresentation, and that is where we return the system type. IntentValueRepresentation is how we return it. Here we point it at our person property with a key path, but we can also pass a closure and build the IntentPerson inline, which is what the importing example does later on.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Importing content from another app

Once content is exported, any app interested in that content can read it. Importing is the other side of that exchange.

Continuing the example above, an app that wants to handle an incoming IntentPerson — ours or anyone else's — has two cases to account for:

  • Something that already exists in our app, such as a contact we've saved. Use IntentValueQuery to find the matching entity.
  • Something entirely new, such as a contact that isn't in our app yet. Use the importing closure of IntentValueRepresentation to create a new entity from the incoming value.
An incoming IntentPerson can match an existing entity using IntentValueQuery or create a new entity using IntentValueRepresentation.
Match an existing entity or create a new one from incoming content. Source: Apple's Build intelligent Siri experiences with App Schemas (19:11).

How to differentiate incoming intent

The two cases aren't a choice we make once. We implement both, and the incoming value decides which one applies.

Nothing on an IntentPerson tells us whether we already have that contact, so we look in our own data. If the incoming person matches something we've saved, we return the entity we already have. If nothing matches, there is nothing to return, and we create the content instead.

That means the deciding work happens in the matching step, so let's start there.

Matching an existing entity

When incoming content should resolve to something already in our app, we adopt IntentValueQuery and implement values(for:).

We never call the query ourselves. The system finds it through our app's App Intents metadata, creates it, and calls values(for:) with the incoming content. Our job is to answer with the matching entities.

The method receives IntentPerson values and returns matching ContactEntity values. Here, the app reads the incoming names and searches its saved contacts:

struct ContactEntityQuery: IntentValueQuery {
func values(for input: [IntentPerson]) async throws -> [ContactEntity] {
let names = input.map(\.displayName)
let descriptor = FetchDescriptor<Contact>()
let contacts = try model.mainContext.fetch(descriptor)
let matches = contacts.filter { contact in
names.contains(where: { name in
contact.name.localizedStandardContains(name)
})
}
return matches.map(\.entity)
}
}

Two types are in play here. Contact is our own model, the data our app stores, while ContactEntity is what we expose to the system. contact.entity turns one into the other, which is why the query fetches Contact values but returns ContactEntity values.

Our app decides how to find the matching content. This example matches on names, but we could match on a phone number, an email address, or an ID we saved earlier.

matches can come back empty, and that is how we say we have nothing for this content. An empty array means no match, which is what leads to a new entity. We throw only when something actually goes wrong, such as a failed fetch.

Creating a new entity

When incoming content should create something new in our app, we add an importing closure to IntentValueRepresentation.

The closure receives the incoming IntentPerson and returns a new ContactEntity. Here, the app creates a contact, adds it to its contact list, and returns that contact's entity:

extension ContactEntity: Transferable {
static var transferRepresentation: some TransferRepresentation {
IntentValueRepresentation(exporting: \.person, importing: { intentPerson in
let contact = Contact(importing: intentPerson)
ContactManager.shared.contacts.append(contact)
return contact.entity
})
}
}

Our app handles creating the content and returning the new entity to the system. Notice that both directions live in the same declaration: exporting hands our contact out, and importing turns an incoming one into an entity.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Summary

Content transfer is what lets another app act on our entity. Exporting is a declaration: adopt Transferable and describe the value we hand over with IntentValueRepresentation. Importing is a decision: resolve the incoming value to something we already have with IntentValueQuery, or build something new in the importing closure.

Combined with the on-screen awareness from Part 1, Siri can identify the content we're looking at and hand it to another app to work with.


Read more article about iOS, Siri, App Intents, or see all available topic

Enjoy the read?

If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.

Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Buy me a coffee Tweet Share
Previous
Build Siri experiences across apps, Part 1: Onscreen Awareness

Connect your views to App Entities so Siri can resolve "this" to the content on screen, using NSUserActivity annotations or view annotations.

← Home