FlutterアプリでShare Extensionを使う

# Share Extensionって何？
あるアプリを、そのアプリの外側から操作したりするための拡張機能App Extensionの1つ。
App Extensionを使えば、例えば、以下の画像のようにSafariの共有ボタンからあるアプリにデータを転送したり（Share Extension)、ホーム画面左に今日の天気Widgetを表示したり（Today Extension)することができる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/2dbf24df-f93c-dea0-5e81-c15195866869.png)
App Extensionの種類のまとめとしては、以下の記事がとても参考になりました。
[iOSのApp Extensionまとめ - Satsuki Hashiba - Medium](https://medium.com/@shiba1014/ios%E3%81%AEapp-extension%E3%81%BE%E3%81%A8%E3%82%81-6d311b542cbc)

>App ExtensionはiOS(& OS X)での呼び方で、AndroidではIntent(?)を使って同じようなことができそう。

Share Extensionで共有できるものは、文字列、画像、URL、ファイルなどの種類があるが、
今回はURL+メッセージなどの情報をアプリに渡すサンプルを作ってみて、前回作った[メタデータを表示するアプリ](https://qiita.com/popy1017/items/732229abc14e73933269)と連動させたい。

FlutterでShare Extensionを実装するため、今回は以下のパッケージを使う。
[receive_sharing_intent | Flutter Package](https://pub.dev/packages/receive_sharing_intent)

>上記パッケージのIssueを見たら、危ないよ〜・バグあるよ〜(適当)って言っている人がいたので、未成熟なのかもしれない。。。

# やること
1. Safariの共有メニューから現在表示しているWebサイトのURLと自分で決めたメッセージを転送するデモアプリを作る
2. 前回作ったメタデータを表示するアプリと統合してみる

# デモアプリを作る
基本的にはパッケージ公開サイトに書かれているReadme通りにやっていく。

## Flutter側の準備
### 新規プロジェクトを作成
```shell
flutter create my_share_extensin
cd my_share_extension
```
### receive_sharing_intentパッケージのインストール
```yaml
dependencies:
  flutter:
    sdk: flutter

  // ↓↓↓↓↓↓↓↓↓↓
  receive_sharing_intent: ^1.4.0+2
```
>**注意:**
Flutter内で`receive_sharing_intent`パッケージの関数とかを使っていなくても、
Share Extensionを追加した状態でのビルド時にこのパッケージがインストールされていないと`ld: framework not found Flutter`というエラーが出てビルドに失敗する。

### main.dartの修正
細かいところは置いておいて、パッケージのReadmeの最後に書かれているコードをmain.dartに丸々コピーする。

```dart:main.dart
import 'package:flutter/material.dart';
import 'dart:async';

import 'package:receive_sharing_intent/receive_sharing_intent.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  StreamSubscription _intentDataStreamSubscription;
  List<SharedMediaFile> _sharedFiles;
  String _sharedText;

  @override
  void initState() {
    super.initState();

    // For sharing images coming from outside the app while the app is in the memory
    _intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream()
        .listen((List<SharedMediaFile> value) {
      setState(() {
        print("Shared:" + (_sharedFiles?.map((f) => f.path)?.join(",") ?? ""));
        _sharedFiles = value;
      });
    }, onError: (err) {
      print("getIntentDataStream error: $err");
    });

    // For sharing images coming from outside the app while the app is closed
    ReceiveSharingIntent.getInitialMedia().then((List<SharedMediaFile> value) {
      setState(() {
        _sharedFiles = value;
      });
    });

    // For sharing or opening urls/text coming from outside the app while the app is in the memory
    _intentDataStreamSubscription =
        ReceiveSharingIntent.getTextStream().listen((String value) {
      setState(() {
        _sharedText = value;
      });
    }, onError: (err) {
      print("getLinkStream error: $err");
    });

    // For sharing or opening urls/text coming from outside the app while the app is closed
    ReceiveSharingIntent.getInitialText().then((String value) {
      setState(() {
        _sharedText = value;
      });
    });
  }

  @override
  void dispose() {
    _intentDataStreamSubscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    const textStyleBold = const TextStyle(fontWeight: FontWeight.bold);
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          child: Column(
            children: <Widget>[
              Text("Shared files:", style: textStyleBold),
              Text(_sharedFiles?.map((f) => f.path)?.join(",") ?? ""),
              SizedBox(height: 100),
              Text("Shared urls/text:", style: textStyleBold),
              Text(_sharedText ?? "")
            ],
          ),
        ),
      ),
    );
  }
}
```
## Share Extensionを作成する
説明のため、RunnerとShareExtensionのBundle Identifierをそれぞれ`com.example.shareExtensionSample`と`com.exmaple.shareExtensionSample.shareExtension`とする。
### Xcodeを開く
```shell
open ios/Runner.xcodeproj/
```
### ShareExtensionを新規作成する
ツールバーから`File > New > Target`を選択し、表示されたダイアログから`Share Extension`を選択する。
(本記事では`MyShareExtension`という名前を付けたとする)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/014ebd7a-ab9d-d97f-eac6-e9d30987eeb3.png" />

### Runner/Info.plistを編集する
`Runner/Info.plist`を右クリック > `Open As ... Source Code` を選択すると、ソースコードを直接編集できる。
以下のタグを追加する。
Property List表示でも、以下と同じ意味になるようにキーバリューを追加すればOK。

```xml
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>ShareMedia</string>
            </array>
        </dict>
        <dict/>
    </array>
```
### MyShareExtension/Info.plistを修正する
`MyShareExtension/Info.plist`を右クリックして、Open as Source code を選択。
以下のように修正する。パッケージの説明だといろいろ追加しているが、今回はURLの共有だけするので、不要な物は省く。

```xml
〜〜
    <key>CFBundleShortVersionString</key>
	<string>$(FLUTTER_BUILD_NAME)</string> 
	<key>CFBundleVersion</key>
	<string>$(FLUTTER_BUILD_NUMBER)</string>
	<key>NSExtension</key>
	<dict>
		<key>NSExtensionAttributes</key>
		<dict>
			<key>NSExtensionActivationRule</key>
			<dict>
				<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
				<integer>1</integer>
			</dict>
		</dict>
		<key>NSExtensionMainStoryboard</key>
		<string>MainInterface</string>
		<key>NSExtensionPointIdentifier</key>
		<string>com.apple.share-services</string>
	</dict>
〜〜
```

### RunnerとMyShareExtensionを同じApp Groupに登録する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/138ee80b-f96c-4367-d1d0-229a287cb39c.png)
Runnerプロジェクトファイルを選択してプロジェクトの設定を開き、 `TARGETS`の`Runner`を選択。
上のタブから`Signing & Capabilities`を選択し、左上の`+Capability`ボタンを押して出てきたポップアップから`App Groups`を選択する。
`▼Signing`の下に、`▼App Groups`が表示されると思うので、`+`ボタンを押して、新規グループを作成する。
グループ名は`group.{RunnerのBundler Identifier}`とする。（この例では、`group.com.example.shareExtensionSample`)
同様に`TARGETS/MyShareExtension`にも同じグループを追加する。

### MyShareExtension/ShareViewController.swiftを修正する
パッケージの説明だと長いコードが書かれているが、今回はURLのみに対応するので、必要なものを抽出する。
パッケージのサンプルだと`viewDidLoad()`にいろいろ書かれているが、`didSelectPost()`に書かないと挙動がおかしいので注意。

```swift
import UIKit
import Social
import MobileCoreServices
import Foundation

class ShareViewController: SLComposeServiceViewController {

    let hostAppBundleIdentifier = "com.example.shareExtension"
    let sharedKey = "ShareKey"
    var sharedText: [String] = []
    let urlContentType = kUTTypeURL as String
    
    override func isContentValid() -> Bool {
        // Do validation of contentText and/or NSExtensionContext attachments here
        return true
    }

    override func didSelectPost() {
        // This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments.
        if let content = extensionContext!.inputItems[0] as? NSExtensionItem {
            if let contents = content.attachments {
                for (index, attachment) in (contents).enumerated() {
                    if attachment.hasItemConformingToTypeIdentifier(urlContentType) {
                        handleUrl(content: content, attachment: attachment, index: index)
                    }
                }
            }
        } else {
            // Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context.
            self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
        }
    }

    override func configurationItems() -> [Any]! {
        // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here.
        return []
    }
    
    private func handleUrl (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
        attachment.loadItem(forTypeIdentifier: urlContentType, options: nil) { [weak self] data, error in

            if error == nil, let item = data as? URL, let this = self {
                
                this.sharedText.append(item.absoluteString)

                // If this is the last item, save imagesData in userDefaults and redirect to host app
                if index == (content.attachments?.count)! - 1 {
                    let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
                    userDefaults?.set(this.sharedText, forKey: this.sharedKey)
                    userDefaults?.synchronize()
                    this.redirectToHostApp(type: .text)
                }
            } else {
                self?.dismissWithError()
            }
        }
    }
    
    private func dismissWithError() {
        print("[ERROR] Error loading data!")
        let alert = UIAlertController(title: "Error", message: "Error loading data", preferredStyle: .alert)

        let action = UIAlertAction(title: "Error", style: .cancel) { _ in
            self.dismiss(animated: true, completion: nil)
        }

        alert.addAction(action)
        present(alert, animated: true, completion: nil)
        extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
    }
    
    enum RedirectType {
        case media
        case text
        case file
    }

    private func redirectToHostApp(type: RedirectType) {
        let url = URL(string: "ShareMedia://dataUrl=\(sharedKey)#\(type)")
        var responder = self as UIResponder?
        let selectorOpenURL = sel_registerName("openURL:")

        while (responder != nil) {
            if (responder?.responds(to: selectorOpenURL))! {
                let _ = responder?.perform(selectorOpenURL, with: url)
            }
            responder = responder!.next
        }
        extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
    }
}
```
動作はこんな感じ。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Share Extension with Flutter <a href="https://t.co/hXUOS1ndqy">pic.twitter.com/hXUOS1ndqy</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1269442980747685888?ref_src=twsrc%5Etfw">June 7, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# おまけ
## コード内で定数として書かれているBundle Identifierを外出しする
参考記事: [iOSアプリ開発における環境切り替え（Debug⇔Release）方法まとめ - Qiita](https://qiita.com/takehilo/items/2d222748abd4db3da5e1)
Bundle Identifierをコード内に入れる是非がわからなかったので、外出しする方法を調査した。
上記の記事によれば、ProjectのBuildSettingで環境変数のような変数を定義して、ShareExtensionのInfo.plistでその値を指定したキーを設定すればできるらしい。

```swift
struct Configuration {
    static let shared = Configuration()

    private let config: [AnyHashable: Any] = {
        let path = Bundle.main.path(forResource: "Info", ofType: "plist")!
        let plist = NSDictionary(contentsOfFile: path) as! [AnyHashable: Any]
        return plist["AppConfig"] as! [AnyHashable: Any]
    }()

    let hostAppBundleIdentifier: String

    private init() {
        hostAppBundleIdentifier = config["HostAppBundleIdentifier"] as! String
    }
}

~~
  private func handleUrl (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
        attachment.loadItem(forTypeIdentifier: urlContentType, options: nil) { [weak self] data, error in

            if error == nil, let item = data as? URL, let this = self {
                  ~~ 
                    let suiteName = Configuration.shared.hostAppBundleIdentifier
                    // If this is the last item, save imagesData in userDefaults and redirect to host app
                    if index == (content.attachments?.count)! - 1 {
                        let userDefaults = UserDefaults(suiteName: "group.\(suiteName)")
                        userDefaults?.set(this.sharedText, forKey: this.sharedKey)
                        userDefaults?.synchronize()
                        this.redirectToHostApp(type: .text)
                    }
            } else {
                self?.dismissWithError()
            }
        }
    }
~~
```


## json文字列を転送する
参考記事: [Swift: Convert struct to JSON? - Stack Overflow](https://stackoverflow.com/questions/33186051/swift-convert-struct-to-json)

上記のままではURLしか転送できないので、例えばブクマアプリのように自分で編集可能なタイトルと、URLをセットで保持したい時などに困る。
(たぶんネイティブだとAnyで送受信できた気がするけど)FlutterのパッケージだとTextかMediaの受信関数しか用意されていないっぽいので、
力技だが、JSON形式の文字列を送受信することで構造データを送信できるようにする。

```swift
struct Payload : Codable{
    var url: String
    var message: String
}
~~
do {
  let payload = Payload(url: item.absoluteString, message: (self?.contentText)!)
  let payloadJson = try JSONEncoder().encode(payload)
  let stringPayload = String(data: payloadJson, encoding: .utf8)
  
  this.sharedText.append(stringPayload!)

  // If this is the last item, save imagesData in userDefaults and redirect to host app
  if index == (content.attachments?.count)! - 1 {
  ~~
```

## 以前作ったアプリと統合
以前作ったメタデータ表示アプリは状態管理に`ChangeNotifier`を使っていて、パッケージのサンプルコードでは`StatefulWidget`での書き方しか載っていなかったため少し苦戦。
以下のようにしたら動きました。

* `initState()`は`ChangeNotifier`にないので、 サンプルコードの`initState()`で書かれているコードは`ChangeNotifier`拡張クラスのコンストラクタで呼ぶ。
* `setState`は使えないので、モデルで保持している変数を直接書き換える or 書き換える関数を呼ぶ。
* `dispose()`は`ChangeNotifier`で使えるので、そのまま使う。

```dart:
class MetadataModel extends ChangeNotifier {
  Metadata ogp = Metadata.fromJson(mockJson);
  StreamSubscription _intentDataStreamSubscription;

  MetadataModel() {
    // アプリ起動中の処理
    _intentDataStreamSubscription =
        ReceiveSharingIntent.getTextStream().listen((String value) {
      final json = jsonDecode(value);
      if (json["url"] != null) {
        fetchOgpFrom(json["url"]);
      }
    }, onError: (err) {
      print("getLinkStream error: $err");
    });

    // For sharing or opening urls/text coming from outside the app while the app is closed
    ReceiveSharingIntent.getInitialText().then((String value) {
      if (value != null) {
        final json = jsonDecode(value);
        if (json["url"] != null) {
          fetchOgpFrom(json["url"]);
        }
      }
    });
  }

  @override
  void dispose() {
    _intentDataStreamSubscription.cancel();
    super.dispose();
  }

  Future<bool> fetchOgpFrom(String _url) async {
    try {
      final response = await http.get(_url);
      final document = responseToDocument(response);
      ogp = MetadataParser.OpenGraph(document);

      notifyListeners();
      return true;
    } catch (e) {
      print(e.message ?? e);
      return false;
    }
  }
}
```
最終的な動作(結局URLだけしか使ってないことにここで気づく。。。)
<blockquote class="twitter-tweet"><p lang="it" dir="ltr">Share Extension x OGP x Flutter <a href="https://t.co/iN8Sc1ilHD">pic.twitter.com/iN8Sc1ilHD</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1269548781843787777?ref_src=twsrc%5Etfw">June 7, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## その他
### 実機検証時のエラー対処
シミュレーターで動かした時はエラーは出なかった(気づかなかっただけか...?)が、実機にインストールしようとすると、以下のようなエラーが出たのでざっくりした意味合いと対処方法を記録。
#### warning: linking against dylib not safe for use in application extensions
参考記事: [ios - How to suppress warning: linking against dylib not safe for use in application extensions? - Stack Overflow](https://stackoverflow.com/questions/28652102/how-to-suppress-warning-linking-against-dylib-not-safe-for-use-in-application)

RunnerプロジェクトのBuild Settingsの`Require Only App-Extension-Safe API`を**No**に変更すれば警告を消すことができる。
Extensionとそれを含むアプリでコードを共有したい時に`Embedded Framework`というのを作れるが、その際、本来Extensionで使えないはずのAPIを使ってはいけないという決まりがあるらしい。
今回はプロジェクトにFlutterというフレームワークが入っているため警告が出たが、Extensionでは使ってないのでセーフ(？)という理解。
(Yesにしないと審査通らないって言っている人とNoでも審査通ったよって言っている人がいて線引きがよくわからない)

>公式ドキュメント:
You can create an embedded framework to share code between your app extension and its containing app. For example, if you develop an image filter for use in your Photo Editing extension as well as in its containing app, put the filter’s code in a framework and embed the framework in both targets.

>Make sure your embedded framework does not contain APIs unavailable to app extensions, as described in Some APIs Are Unavailable to App Extensions. If you have a custom framework that does contain such APIs, you can safely link to it from your containing app but cannot share that code with the app’s contained extensions. The App Store rejects any app extension that links to such frameworks or that otherwise uses unavailable APIs.

>To configure an app extension target to use an embedded framework, set the target’s “Require Only App-Extension-Safe API” build setting to Yes. If you don’t, Xcode reminds you to do so by displaying the warning “linking against dylib not safe for use in application extensions”.
https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html

#### ld: 'ライブラリ名' does not contain bitcode. You must rebuild it with bitcode enabled (Xcode setting ENABLE_BITCODE)
参考記事: [Xcode7でbitcodeのエラーが出た - Qiita](https://qiita.com/imanishisatoshi/items/8b64af8262d7fe2eb2ba)
>Build Settings > Enable BitcodeをNOにすれば解決する。

Bitcodeの役割についてはこちらがわかりやすかったです。
[NJF Wiki - xpwiki : iOSではBitcodeという仕組みがあり、うまく使うと便利なのですが、コンパイル時にエラーが出てしまうことがあります。 [iOS/Bitcodeをオフにする]](http://njf.jp/cms/modules/xpwiki/?iOS%2FBitcode%E3%82%92%E3%82%AA%E3%83%95%E3%81%AB%E3%81%99%E3%82%8B)