iOSの「ショートカット」アプリとXamarinアプリを連携させてみた話

この記事は [KLab Engineer Advent Calendar 2018](https://qiita.com/advent-calendar/2018/klab) の17日目の記事です。

# 概要

iOSの自動化ツールである「ショートカット」アプリと、x-callback-urlを使った連携をXamarinで実装してみました。

[x-callback-url](http://x-callback-url.com/) のページにはInterAppCommunication(以下IAC)というフレームワーク用意されていますが、これに似たものをXamarin+C#に移植して実装します。

x-callbackも「ショートカット」アプリもiOS用のものなので、iOSユーザが対象です。

## ショートカットとは
簡単に説明すると、自動化マクロをビジュアルプログラミングできるiOSアプリです。スクリプトの共有もできます。
昔は「workflow」と呼ばれていたようですが、名称を「ショートカット」に変えたようです。~~とても検索しづらい。~~

変数や繰り返しなど、基本的なプログラミング機能を備えているほか、「カレンダー」などの標準のiOSアプリだけでなく、「Twitter」「Evernote」などへの操作も標準で用意されています。
SSH経由でスクリプトを実行したり、「Pythonista」(有料アプリ)と連携してPythonコードを実行することもできるようなので、なかなか夢が広がります。

## x-callback-urlとは

URLスキームを利用したiOSアプリ間の連携を行うためのプロトコルです。
アプリAがアプリBを起動し、アプリBの処理が終わったらアプリAに処理が戻る、といった事ができます。URLのクエリパラメータとして引数を受け渡しができるので、細かい連携も可能です。

それなりに昔からあるので、[いろいろなアプリが対応しているようです](http://x-callback-url.com/apps/)。

URLは以下の書式で記述します

```
<scheme>://<host>/<action>?<x-callback parameter>&<action parameter>
```

* scheme: 起動したいアプリのURLスキーム
* host: `x-callback-url`
* action: 実行したい内容。アプリの実装による。
* x-callback parameter: x-callback用の引数。
* action parameter: actionに渡す引数。アプリの実装による。接頭辞に`x-`は付けないほうが良い。

x-callback用の引数は以下のものがあります。

* x-success: 成功した時に起動するURL
* x-cancel: キャンセルした時に起動するURL
* x-error: エラーの時に起動するURL
* x-source: 呼び出したアプリ

# 実装
## 使用環境

* macOS Mojave 10.14.2
* Xcode 10.1(10B61)
* Visual Studio Community 2017 for Mac 7.7(build 1868)
* iOS 12.1.1(16C50)
* ショートカット 2.1.2

## プロジェクトの作成
x-callback-urlでのみ使うので、なるべくシンプルに`iOS>アプリ>単一ビューアプリ`で作ります。

チーム設定やプロビジョニングは慣れないとハマるポイントですが、この記事では割愛します。
おもちゃを作って試す程度ならこのあたりの記事が参考になります。
[【Xcode】AppleDeveloperProgramに登録不要！！実機ビルドする方法](https://qiita.com/natsumo/items/3f1dd0e7f5471bd4b7d9)
[Xamarin StudioでiOSの無料実機テストするまでのメモ書き](https://qiita.com/muak_x/items/98c8e6b36de1f6d012d0)

## URLスキームで起動できるようにする

URLスキームで起動できるようにするためには`Info.plist`を変更する必要があります。
以下のように、`詳細設定>URLを追加`を選択して`URLScheme`を設定します。
今回は`xcallbackurl`で起動できるようにしました。
![urlScheme.png](https://qiita-image-store.s3.amazonaws.com/0/38187/905c7809-cc0f-a1aa-4fa4-45a1ebb17d58.png)

## とりあえず雑にコールバックさせてみる

### ショートカット側

1. 「テキスト」で`xcallbackurl://x-callback-url/test`を設定する。
2. `XコールバックのURLを開く`に渡す。

<img src="https://qiita-image-store.s3.amazonaws.com/0/38187/a78f2181-b56d-4f12-37e3-5afec0b12f35.png" width=320px alt="ショートカットの設定" />

「ショートカット」アプリがx-callbackのパラメータを埋めてくれるので、実際には以下のように呼び出されます。

`xcallbackurl://x-callback-url/test?x-cancel=shortcuts-production://x-callback-url/ic-cancel/82FF00AF-11AF-4A86-A94C-E7DB7EB69950&x-error=...`


ここからx-sucessを取り出して実行してみます

``` AppDelegate.cs
using System;
using System.Web;
// 略

namespace xcallbackurl
{
    [Register("AppDelegate")]
    public class AppDelegate : UIApplicationDelegate
    {
        // 略

        [Export("application:openURL:options:")]
        public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options)
        {
            var uri = new Uri(url.AbsoluteString);
            var query = HttpUtility.ParseQueryString(uri.Query);
            var xSuccess = query.Get("x-success");
            if (!string.IsNullOrEmpty(xSuccess))
            {
                app.OpenUrl(new NSUrl(xSuccess + "?hoge=fuga"), new NSDictionary(), null);
            }
            return true;
        }
    }
}
```

まずURLスキームで起動された場合のURLからクエリを取り出します。
次に `x-success` の返答URLに `hoge=fuga` というパラメータを追加してコールバックを返します。

ちなみに初期状態では `System.Web` がなかったので、 `System.Web.Services` のパッケージを追加しました。 `プロジェクト>参照の編集` から追加できます。

### 実行してみる

<img src="https://qiita-image-store.s3.amazonaws.com/0/38187/78dbe66a-d74d-726f-67ee-626422f2c1d6.png" width=320px alt="実行結果"/>

実行すると一度作成したアプリが起動し、すぐに「ショートカット」がアクティブになります。戻り値も受け取れました。

## もう少し扱いやすくする

提供されているObjective-Cのフレームワーク[Inter-App Communication(IAC)](https://github.com/tapsandswipes/InterAppCommunication)を参考にC#に移植してみます。「ショートカット」アプリから呼び出せるようになれば十分遊べるので、URLスキームで起動する側ことにしました。

### 実装のためのインタフェースと管理クラスを作る

挙動を実装するためのインタフェースとして `IXCAganet` を作ります。
さらに、インタフェースをまとめるクラスをSingletonで作っておきます。

``` IXCAgent.cs
public delegate void XCSuccess(Dictionary<string, string> parameters);
public delegate void XCCancel();
public delegate void XCError(string errorMessage, string errorCode, string errorDomain);
public interface IXCAgent
{
    bool Supports(string action);

    void Perform(string action, Dictionary<string, string> parameters,
        XCSuccess onSuccess, XCCancel onCancel, XCError onError);
}
```

``` XCManager.cs
public class XCManager
{
    public static XCManager Instance { get; } = new XCManager();
    private XCManager(){}

    HashSet<IXCAgent> agents = new HashSet<IXCAgent>();

    public void AddAgent(IXCAgent agent)
    {
        agents.Add(agent);
    }

    public void ClearAgegnts()
    {
        agents.Clear();
    }
}
```

### URLをパースする

``` XCManager.cs
void ParseUrl(NSUrl url,
    out string action,
    out string xSuccess, out string xError, out string xCancel,
    out Dictionary<string, string> parameters)
{
    var query = HttpUtility.ParseQueryString(url.Query);

    xSuccess = query.Get("x-success");
    xError = query.Get("x-error");
    xCancel = query.Get("x-cancel");

    action = url.Path.TrimStart('/');

    parameters = new Dictionary<string, string>();
    foreach(var key in query.AllKeys)
    {
        var k = HttpUtility.UrlDecode(key);
        if (!k.StartsWith("x-", StringComparison.Ordinal))
        {
            parameters[k] = HttpUtility.UrlDecode(query.Get(k));
        }
    }
}
```

x-callback用のパラメータとアクション用のパラメータを分解しています。
簡略化のため、使う予定のない `x-source` は取得していません。
機能実装で利用する `parameters` はC#で扱いやすいDictionary
で処理します。
URLスキーム自体は同じキーを複数登録できてますが、混乱の元なのでキーが単一になるように制限してみました。

### クエリパラメータを含んだNSUrlを生成する関数

``` XCManager.cs
NSUrl CreateUrl(string xcbUrl, Dictionary<string, string> parameters)
{
    if (parameters == null || parameters.Count == 0)
    {
        return new NSUrl(xcbUrl);
    }

    var url = new StringBuilder(xcbUrl);
    var separate = '?';
    foreach(var parameter in parameters)
    {
        url.Append(separate);
        url.Append(HttpUtility.UrlEncode(Uri.EscapeUriString(parameter.Key)));
        url.Append('=');
        url.Append(HttpUtility.UrlEncode(Uri.EscapeUriString(parameter.Value)));
        separate = '&';
    }
    return new NSUrl(url.ToString());
}

NSUrl CreateErrorUrl(string xcbUrl, string message, string code, string domain)
{
    var parameters = new Dictionary<string, string>()
    {
        { "errorMessage", message },
        { "error-Code", code },
        { "errorDomain", domain }
    };
    return CreateUrl(xcbUrl, parameters);
}
```

DictionaryにまとめたクエリパラメータをURLに変換する処理です。
エラー時のクエリパラメータは、参考にしているIACの実装に合わせたキーを設定するようにしました。

URLエンコードは `HttpUtility.UrlEncode(urlString)` を単純に呼ぶだけでは駄目でした。
`HttpUtility.UrlEncode` はスペースを `+` に変換しますが、ショートカットは `%20` になる想定で、エンコード方法に差があるのが原因のようです。
`HttpUtility.UrlEncode(Uri.EscapeUriString(urlString))` とした所、ショートカット側で正しくデコードできる形式になりました。

おそらくこんな面倒な事をしなくても簡単な方法はありそうですが、自分の検索能力では見つからず。。。力不足を実感します。

### 用意したパーツをつなぎ合わせてIXCAgentを処理する

```XCManager.cs
public void ProcessAgents(UIApplication app, NSUrl url)
{
    if (url.Host != "x-callback-url")
    {
        return;
    }

    ParseUrl(url,
        out string action,
        out string xSuccess, out string xError, out string xCancel,
        out Dictionary<string, string> parameters);
    var onSuccess = dict =>
        app.OpenUrl(CreateUrl(xSuccess, dict), new NSDictionary(), null);
    var onCancel = () =>
        app.OpenUrl(new NSUrl(xCancel), new NSDictionary(), null);
    var onError = (message, code, domain) =>
        app.OpenUrl(CreateErrorUrl(xError, message, code, domain), new NSDictionary(), null);

    try
    {
        var supportedAgent agents.FirstOrDefault(agent => agent(action));
        if (supportedAgent == null)
        {
            var callbackUrl = CreateErrorUrl(xError, "NotFoundAction", "1", "Client");
            app.OpenUrl(callbackUrl, new NSDictionary(), null);
            return;
        }
        supportedAgent(action, parameters, onSuccess, onCancel, onError);
    } catch(Exception e) {
        var callbackUrl = CreateErrorUrl(xError, e.Message, "1", "XCManager");
        app.OpenUrl(callbackUrl, new NSDictionary(), null);
    }
}
```

大まかに以下の処理をしています。

1. URLをパースする
2. コールバック用のdelegateを生成する
3. 登録された呼び出したいactionをサポートしているAgentを見つける
4. Agentが見つかれば処理。なければエラー。

Agent中ではどんな実装をしているのか分からないので、雑にtry-catchで囲んでおきます。
エラーを握りつぶしているので若干怖いですが、ここでは規格通りに呼び出されたx-callbackなら確実に完了するような実装に倒しました。

### 足し算をするだけのactionを作る

まず書式を考えます。

入力例: `add?lhs=2&rhs=4.2`
結果例: `result=6.2`

ついでに文字列のエンコーディングを確認するため、出力用の文字を指定できるように拡張しておきます。

入力例: `add?lhs=2&rhs=4.2&text=result`
結果例: `result=result%3D6.2`

これを実装すると以下のような感じになります。
入力ミスに気づきやすいように、actionの引数を処理できない時は専用のエラーを出すようにしました。

``` AddAgent.cs
class AddAgent : IXCAgent
{
    public bool Supports(string action)
    {
        return action == "add";
    }

    public void Perform(string action, Dictionary<string, string> parameters,
        XCSuccess onSuccess, XCCancel onCancel, XCError onError)
    {
        string result;
        try
        {
            var lhs = Double.Parse(parameters["lhs"]);
            var rhs = Double.Parse(parameters["rhs"]);
            var sum = lhs + rhs;
            result = parameters["text"] + "=" + result.ToString();
        } catch (Exception e) {
            onError("ParameterError", "1", "AddAgent");
            return;
        }

        var callbackParams = new Dictionary<string, string>
        {
            { "result", result }
        };
        onSuccess(callbackParams);
    }
}
```

### actionの処理を登録/登録解除する

```AppDelegate.cs
        public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions)
        {
            XCManager.Instance.AddAgent(new AddAgent());
            return true;
        }

        public override void OnResignActivation(UIApplication application)
        {
        }

        public override void DidEnterBackground(UIApplication application)
        {
            XCManager.Instance.ClearAgegnts();
        }

        public override void WillEnterForeground(UIApplication application)
        {
            XCManager.Instance.AddAgent(new AddAgent());
        }

        public override void OnActivated(UIApplication application)
        {
        }

        public override void WillTerminate(UIApplication application)
        {
            XCManager.Instance.ClearAgegnts();
        }

        [Export("application:openURL:options:")]
        public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options)
        {
            XCManager.Instance.ProcessAgents(app, url);
            return true;
        }
```

非アクティブ中にメモリが開放されたりするので、アクティブになった時にactionを登録し直しておきます。
念の為、多重登録にならないように非アクティブになる時には登録を解除しておきます。

こちらの記事を参考にさせていただきました。
[AppDelegateのメソッドが呼ばれるタイミングと実装すべき内容（iOS11）](https://qiita.com/mfujiwara/items/cf3d61a96cf4fdfc1162)

## 呼び出してみる

### 「ショートカット」アプリの設定

以下のように組みました

<table><tr>
 <td>
  <img src="https://qiita-image-store.s3.amazonaws.com/0/38187/39678489-307d-1752-a29a-d6157010c0cf.png" width=320px alt="shortcut02-1/2" />
 </td>
 <td>
  <img src="https://qiita-image-store.s3.amazonaws.com/0/38187/17b0c2ba-3573-a729-20f8-d8f52faac541.png" width=320px alt="shortcut02-2/2" />
 </td>
</tr></table>

### 実行してみる

<img src="https://qiita-image-store.s3.amazonaws.com/0/38187/70b31575-6414-b33a-6a73-c70e80edf04e.png" width=320px alt="shortcut02-result" />

` ` や `&` もエンコードで壊れることなく、コールバックを実行できました。

# IACを参考に移植してみた所感

## エラー処理

Objective-Cの `NSError` が元々持っているためだと思いますが、`errorCode` や `errorDomain` といった情報を持っているのは便利そうでした。
IACではもう少しうまくエラー処理をしていて、利用者側が意識しなくても、それなりにわかりやすいドメインが選ばれるようになってました。

## x-cancel

IACでは `IACDelegate` に `OnSuccess` と `OnFailure` しか用意されていません。
Successがboolで成功かキャンセルを判断するようになっています。
この辺りの意図はあまりつかめませんでした。

キャンセルでは戻り値用の辞書を渡さないので、この記事の実装では使わない引数は受け取らずに済むように、明確に関数を分けました。

## 実行効率

この記事の実装では、利用可能なAgentを毎回取得していますが、IACではactionをキーとした辞書から処理をO(1)で呼び出しているようでした。

# その他

## CanOpenURL
`UIApplication.CanOpenURL`という関数があります。指定したSchemeのアプリがインストールできるか調べるための関数です。
いかにも `UIApplication.OpenURL` を実行する前に呼んだほうが良さそうに見えますが、x-callbackでの用途なら **呼ばないのが正解** のようです。

というのも、iOS9以降では何もしないと **必ずfalseが返ります** 。
正しく判定するには、Info.plistに `LSApplicationQueriesSchemes` の配列項目を作り、調べたいschemeを列挙しておく必要があります。

x-callbackはどんなアプリから呼び出されるのか分からないので、予め列挙しておくことができません。
どうしてもURL開けたかどうか確認したいなら `UIApplication.OpenURL` のcallbackで判定すると良さそうです。

ややこしいですが、多分アプリがインストールされているのか判断できてしまうので、プライバシーの点で問題になったのでしょう。

## 複数の戻り値を返す場合

この記事では戻り値として1つのクエリパラメータを渡すものしか実装しませんでしたが、複数のクエリパラメータを渡した場合は「ショートカット」アプリでの扱い方が以下のように違います。

1つのクエリパラメータが返った時は、キーを捨てた値だけが文字列として次の処理に渡されます。
複数のクエリパラメータが返った時は、すべてのキーを含んだ辞書として扱います。
辞書はJsonに変換したり、標準機能でキーを指定して取り出すこともできます。

# まとめ

個人で楽しむ分には困らないくらいには自由に拡張できるようになりました。

これ以外にも、IACのバインディングライブラリを作成してXamarinから呼び出せるようにしたり、「Pythonista」アプリを購入してPythonで拡張したり、標準機能では出来ない拡張機能を呼び出す方法はいろいろあります。

振り返ると、数ある方法の中でも茨の道を進んだ気もしますが、こういうライブラリは偉い人の知恵が詰まっているので、車輪の再発明とはいえ適度に勉強になる題材でした。
自分なりの工夫もいくつか入れてみましたが「いやいや！元の方が良いよ！」などのツッコミもお待ちしております。

最後まで読んで頂き、ありがとうございました。

# 参考

[iOSアプリ間連携の実装に x-callback-url を使う](https://techlife.cookpad.com/entry/2014/11/14/143841)
[x-callback-urlを使ってみた](http://yebisupress.dac.co.jp/2015/06/22/x-callback-url%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%9F/)
[【Swift】特定のアプリがインストールされているかどうかで処理を分ける 2017完全版 - URL SchemesとLSApplicationQueriesSchemes -](https://qiita.com/nagaoyuriko/items/67c5e262f6e88cd88885)
[How do I replace all the spaces with %20 in C#?](https://stackoverflow.com/questions/1517586/how-do-i-replace-all-the-spaces-with-20-in-c)