Open10

SwiftUI: AppIntentsでSiriから入力する

kabeyakabeya

iOS 16から、SiriKitを直接使うのではなく、AppIntentsフレームワークというのを使って、それなりに簡単にSiriとかショートカットとかに対応できる、ということらしいのです。
さっそくやってみようとしているのですが、なかなかうまく行きません。
サンプルもドキュメントも少ないようでみんな手探りなのかとも思います。

以下、環境はiOS 16(16.7)、Xcode 15.0 (15A240d)です。

サンプルアプリ

Siriからの音声入力で記事を追加できる日記アプリ、というような想定で作成しました。

動作イメージは以下のビデオのような感じになります。
(音声付きなので注意。私がしゃべってます)

https://www.youtube.com/shorts/ML33jFVwU0Y

コード

動画のアプリ「ウソ日記」ですがソースは1ファイルです。一瞬「おお?」って思うような動作をしますが、100行ぐらいのシンプルなものです。
Signing&CapabilitiesでSiriを追加しておく必要があります。

UsoNikkiApp.swift
import SwiftUI
import AppIntents

@main
struct UsoNikkiApp: App {
    @StateObject var model: UsoNikkiModel = UsoNikkiModel.shared
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(model)
        }
    }
}

struct UsoNikkiNote: Hashable {
    var date: Date
    var content: String
}

class UsoNikkiModel: ObservableObject {
    @Published var notes: [UsoNikkiNote] = []
    
    static var shared: UsoNikkiModel = UsoNikkiModel()
}

struct ContentView: View {
    @EnvironmentObject var model: UsoNikkiModel
    
    var body: some View {
        VStack(alignment: .leading) {
            List {
                Section("記事一覧") {
                    ForEach(model.notes, id: \.self) { note in
                        VStack(alignment: .leading) {
                            Text(note.date.formatted(date: .numeric, time: .standard))
                                .font(.footnote)
                            Text(note.content)
                        }
                    }
                }
            }
        }
    }
}

struct AddNoteIntent: AppIntent {
    static var title: LocalizedStringResource = "記事を追加する"
    
    @Parameter(title: "内容")
    var note: UsoNikkiNoteContentEnum
    
    @MainActor
    func perform() async throws -> some IntentResult {
        UsoNikkiModel.shared.notes.append(UsoNikkiNote(date: Date.now, content: self.note.rawValue))
        return .result()
    }
    
    static var openAppWhenRun: Bool = true
}

enum UsoNikkiNoteContentEnum: String, AppEnum {
    case なんとなく頭髪が増えてきた
    case 今日宝くじで5億円当たった
    case 今朝靴を履こうとしたらなぜか両方右だった
    
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "ウソ日記の内容のEnum")
    static var caseDisplayRepresentations: [Self : DisplayRepresentation] {
        [
            .なんとなく頭髪が増えてきた: "なんとなく頭髪が増えてきた",
            .今日宝くじで5億円当たった: "今日宝くじで5億円当たった",
            .今朝靴を履こうとしたらなぜか両方右だった: "今朝靴を履こうとしたらなぜか両方右だった"
        ]
    }
}

struct UsoNikkiShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] = [
        AppShortcut(intent: AddNoteIntent(),
                    phrases: [
                        "\(.applicationName)に記事を追加\(\.$note)",
                        "\(.applicationName)\(\.$note)って追加して",
                        "\(\.$note)って\(.applicationName)に追加",
                    ],
                    shortTitle: "記事を追加",
                    systemImageName: "square.and.pencil")
    ]
    
}

何がうまくいかないのか

ほとんどのAppIntents+SiriのサンプルはAppEnumを使っているんですね。

このサンプルソースもUsoNikkiNoteContentEnumというEnumで、記事内容の文言をベタ打ちしています。
その通りに発話するとSiriがマッチングしてちゃんとAddNoteIntentを呼んでくれます。
逆にEnumの通りに発話しないで、例えば「今日5億円当たった」ではなく「昨日5億円当たった」とかって言い間違えるともうダメです。(標準アプリの「リマインダー」に追加したりします)

なので、$noteの部分はフリーワードを入れられるようにしたいわけですが、var note: UsoNikkiNoteContentEnumの部分をvar note: Stringに変えると、もうマッチングしてくれません。

AppEntityという手もあると思ったのですが、これもいま一つSiriでどう扱われるのかよく分かりませんでした。
AppEntity.defaultQueryという仕組みでキーワードマッチングが行われるようなイメージかと思っていましたが、EntityQuery.entities(matching:)EntityQuery.entities(for:)EntityQuery.suggestedEntities()が呼ばれている気配がありません。

なんとなくResolverというのが関係しているのではないかという気がするのですが、あんまり説明がないのです。

https://developer.apple.com/documentation/appintents/parameter-resolution?changes=_5

逆を言えば、選択肢がEnumで限定できるケースでは、上記サンプルのように非常に楽に作れるように見えます。

フレーズのローカライズは、AppShortcuts.stringsというファイルを用意して、

"${note}って${applicationName}に追加" = "Add a note that ${note} to ${applicationName}";

というような感じで書けば良いようです。
(他のローカライズ可能文字列は通常通りLocalizable.stringsに追加)
ローカライズについては、以下を参考にさせてもらいました。

https://sowenjub.me/writes/localizing-app-shortcuts-with-app-intents/

kabeyakabeya

https://qiita.com/norippy_i/items/d2fb728837a62a401782

この記事を見て「Siri Intents」と「Siriショートカット」という2種類があることが分かりました。
動きを見ていると「AppIntents」は「Siriショートカット」相当であって「Siri Intents」のようなことまではできないのではないかという気がします。

まだiOS 16で登場したばかりですしiOS 17でも追加部分があるようなので、徐々にできることが増えると思いますが。


追記)

https://developer.apple.com/videos/play/tech-talks/10168/

上のビデオはSiriKitのIntentからApp Intentsに移行する手順を紹介するものですが、2:15ぐらいに
「SiriKit Intentも引き続き完全にサポートされるため、メッセージングやメディアといったSiriの領域用に構築している場合(中略)そのままにしておいてください」
というようなことを言っています。

App IntentsはSiriショートカット用、ということのようですね。

kabeyakabeya

https://github.com/erikfloresq/SiriKit

このサンプルを見習って「ウソ日記」にもSiri Intent Extensionを追加してみたものの、全然動きません。
というか、上記サンプルの「Notify」も全然動かない。
ところがInfo.plistに「Bundle display name」で「ノーティファイ」という値を追加すると動きます。

というわけで「ウソ日記」にも「Bundle display name」を追加して別の名前(一般名詞でないもの)に変えてみると…。
動きました。
ちなみに「リオレウス」としてみました。
「リオレウスにメモを追加して」みたいな。同じフレーズで「ウソ日記にメモを追加して」はダメ。

「ウソ日記を起動して」は「ウソ日記」が起動します。この場合はIntentは関係なくSiriだけで閉じています。「メモを追加」はIntentに処理が渡ってくる必要がありますが、「ウソ日記にメモを追加して」だと処理が来ません。「リオレウスにメモを追加して」だと来ます。

これ、WordとかPagesとかあるいはXとか、Siriで大丈夫なのか心配になりますね。

前にも一度Intent作ってやっぱり処理が来ずに挫折したことがあったのですが、こういうことなんですね。
アプリ名はちょっと考えよう。

kabeyakabeya

「ウソニッキ」でもダメでしたが、「ウッソーンニッキー」だったら大丈夫でした(笑)。

アプリ名決めるときは、まあまあテストが要りますね。

kabeyakabeya

Siri IntentのINAddTasksIntentHandlingresolveTargetTaskListで、needsValue()を返してSiriが再確認しても、intent.targetTaskListにフレーズが入ってきません。

https://developer.apple.com/forums/thread/119232

これ見ると4年も前から(2019年?)「バグじゃないか」という話のようで、私なんかは「そんなはずないんじゃ?」とか思ってましたが、実際自分で試してみるとどうにもならない。

  1. resolveTargetTaskListtargetTaskListnilなのでneedsValue()を返す
  2. Siriが「どのリストですか?」って聞く
  3. resolveTargetTaskListがもう一回呼ばれるが、聞いたリスト名がどこにも入ってない

たぶん、当初(2018年とか)は2で聞いたリスト名が3でtargetTaskListに入ってたんじゃないかと思うんです。
別のサイトの記事もそうなってます。
(というかたぶんこれを参考にして、最初の質問が書かれたように見えます)

https://www.smashingmagazine.com/2018/04/sirikit-intents-app-guide/

iOS 12のiPhone 5sがあるので、ちょっと試してみましょうか…


追記)

@Publishedとか@EnvironmentObjectとかがiOS 13以降でしか使えない、というような話で、アプリ側はだいぶつらいですね。
Intentだけ試す感じですかね。


iCloudのログインに時間がかかりましたが(エラーが出ててログインできなかったのが、しばらく放置して気がついたらログインしてた)、なんとか試せました。
(iPhone 5sの場合、Siriがデバイス上で音声認識せずにiCloudに音声データを投げて認識させてる様子)

結局、同じでした。needsValue()の回答がどこにも入ってこない。

ただ私のiPhone 5sのiOS 12.5.7というのは2023年リリースなので、当時の動きと違う可能性もあります。


iOS 12ですがよくよく試すと、言い方によって入ったり入らなかったりですね。入ることがあります。

入るケース

  1. 「<アプリ名>にXXというタスクを追加して」
  2. Siri:どのリストに追加しますか?→このときintent.taskTitlesの配列にXXが入ってくる。
  3. 「YY」→このときintent.targetTaskListにYYが入ってくる。
  4. Siri:はい、<アプリ名>で「XX」という項目を追加しました

入らないケース

  1. 「<アプリ名>にタスクを追加して」
  2. Siri:どのリストに追加しますか?→このときintent.taskTitlesは空配列のまま。
  3. 「YY」→このときintent.targetTaskListはnilのまま。
  4. Siri:どのリストに追加しますか?
  5. 「YY」
  6. Siri:どのリストに追加しますか?…以下無限ループ

iOS 16でも上記の「入るケース」を試しましたが、iOS 12のようにはならないですね。 2でXXが入ってこないし、 (追記:XXは入ってきてました)3でYYも入らない。
明らかに動きが変わっています。そして多分いまはバグあり状態っぽいですね。

kabeyakabeya

ちょっとメモ。
iOS 12で、Extensionの調査したいとかの目的で、Appが形だけ要る場合。

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
}

とだけ書く。

kabeyakabeya

なので(?)、iOS 16ではresolveTargetTaskListで新規タスクリストの名前をSiriからIntentが受け取る方法がない、ということになります。
代わりにやれそうなのは以下のような動きかなと思います。

  • 案1:タスクリスト名が指定されてない場合、デフォルトのリストにしてしまう(たぶん、標準のリマインダーの動きはこっち)。標準のリマインダーはどういう理屈でデフォルトを決めてるのか知らないけど、試す限りそれっぽいのが選ばれてきています。少なくとも「先頭の」とかではないので、この辺どう決めるかはセンスが問われそう。
  • 案2:タスクリスト名が指定されてない場合、候補を提示し選ばせる。
    このとき、候補として「優先度の高いものいくつか」+「その他」、みたいに提示して、もし「その他」を選ぶとさらに追加の候補を提示、とかもできます。
    候補はSiriが読み上げます(連続実行してると2回目以降は読まないみたいですが)。候補通りあるいはSiriが読み上げたとおりに発話しなくても、近いのを選んでくれるみたいです。

以下は案2のサンプルです。

    func resolveTargetTaskList(for intent: INAddTasksIntent, with completion: @escaping (INAddTasksTargetTaskListResolutionResult) -> Void) {
        if let taskList = intent.targetTaskList {
            if taskList.title.spokenPhrase == "その他" {
                let candidateList = makeTaskListCandidate(names: ["プレゼント", "旅行", "未定"])
                completion(INAddTasksTargetTaskListResolutionResult.disambiguation(with: candidateList))
            }
            else {
                completion(INAddTasksTargetTaskListResolutionResult.success(with: taskList))
            }
        }
        else {
            let candidateList = makeTaskListCandidate(names: ["買い物", "出張", "その他", "未定"])
            completion(INAddTasksTargetTaskListResolutionResult.disambiguation(with: candidateList))
        }
    }
    func makeTaskListCandidate(names: [String]) -> [INTaskList] {
        var candidateList: [INTaskList] = []
        for name in names {
            candidateList.append(INTaskList(title: INSpeakableString(spokenPhrase: name), tasks: [], groupName: nil, createdDateComponents: nil, modifiedDateComponents: nil, identifier: nil))
        }
        return candidateList
    }

この例だと、

  1. 「<アプリ名>にタスクを追加して」
  2. Siri:どのリストに追加しますか?買い物または出張、その他、未定
  3. 「その他」
  4. Siri:どのリストに追加しますか?プレゼントまたは旅行、未定
  5. 「プレゼント」
  6. Siri:何をリマインドしますか
  7. 「ケーキを買う」
  8. Siri:はい、追加しました。

というような感じになります。

kabeyakabeya

Siri:何をリマインドしますか
→「りんご」
って言うとなぜかWikipediaでりんごを調べて得意げに読み始めてしまう。

「みかん」「いちご」だと、アプリに情報が渡ってきて、ちゃんとデータを追加することができます。

なんなんだ「りんご」。Apple製品だから?

kabeyakabeya

色々試してますが、Siriに自前アプリのメモやタスクを登録させるのは結構厳しいですね。

自前のIntentのresolveTargetTaskListに標準のリマインダーのタスクリスト名が渡ってくることがあります。
どこから取ってきてるんすか?ってなります。