flutter_sharing_intentのハマったところと実装解説

株式会社Neverの近藤です。

株式会社Neverはモバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽に[こちら](https://neverjp.com/contact/
)へお問合せください。


今回はflutter_sharing_intentで僕が実装の時にハマった点について記載します。

https://pub.dev/packages/flutter_sharing_intent

# 目次
[1.概要](#1-概要)
[2.画面イメージ](#2-画面イメージ)
[3.ハマったところ](#3-ハマったところ)
[4.実装](#4-実装)
[5.まとめ](#5-まとめ)


# 概要
Flutterアプリで他のアプリから共有されたデータ（テキストやファイルなど）を取得することができます。
iOSでいうところのShare Extensionのことです。
今回実装した時にハマったので備忘録も込めて投稿します。

# 画面イメージ

![flutter_sharing_intent.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/c1bbec74-c0e6-3f07-9212-5c0fd72aaa18.gif)

# ハマったところ
Androidはドキュメント通りに実装して期待通りの挙動になったのですが、
iOSでFlutterアプリに共有したデータが`null`になりハマりました。

原因は、[SharingFile.swift](https://github.com/bhagat-techind/flutter_sharing_intent/blob/main/ios/Classes/SharingFile.swift)の`CodingKeys`の`value = "path"` になっているのですが、Flutter側のパッケージの[SharedFile.fromJson](https://github.com/bhagat-techind/flutter_sharing_intent/blob/bde8309e24c284c0e0165eaaa2a532c2b167e330/lib/model/sharing_file.dart#L24)が`value`になっているためでした。

**SharingFile.swift**
```swift
    enum CodingKeys: String, CodingKey {
        case value = "path"
        case thumbnail
        case duration
        case type
    }
```

**sharing_file.dart**
```dart
  SharedFile.fromJson(Map<String, dynamic> json)
      : value =  json['value'],
        thumbnail = json['thumbnail'],
        duration = json['duration'],
        type = SharedMediaType.values[json['type']];
```

修正のPRも出ていたのマージされるのを待ちつつ、現状はForkして該当箇所修正して実装しました。

https://github.com/bhagat-techind/flutter_sharing_intent/pull/39


# 実装
ここからはドキュメントに記載してある実装手順を少し解説しながら記載します。

## Android

Androidはパッケージのドキュメントに記載してある通り、AndroidManifestに追記のみです。

```xml

    <activity
            android:name=".MainActivity"
            android:configChanges="orientation|keyboardHidden|screenSize"
            android:exported="true"
            android:theme="@style/LaunchTheme"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize"
            android:screenOrientation="portrait"
            android:launchMode="singleTask">

            <!--TODO:  Add this filter, if you want support opening urls into your app-->
            <intent-filter>
               <action android:name="android.intent.action.VIEW" />
               <category android:name="android.intent.category.DEFAULT" />
               <category android:name="android.intent.category.BROWSABLE" />
               <data
                   android:scheme="https"
                   android:host="example.com"
                   android:pathPrefix="/invite"/>
            </intent-filter>

            <!--TODO: Add this filter, if you want to support sharing text into your app-->
            <intent-filter>
                <action android:name="android.intent.action.SEND" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="text/*" />
            </intent-filter>

            <!--TODO: Add this filter, if you want to support sharing images into your app-->
            <intent-filter>
                <action android:name="android.intent.action.SEND" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="image/*" />
            </intent-filter>

            <!--TODO: Add this filter, if you want to support sharing multi images into your app-->
            <intent-filter>
                <action android:name="android.intent.action.SEND_MULTIPLE" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="image/*" />
            </intent-filter>

            <!--TODO: Add this filter, if you want to support sharing videos into your app-->
            <intent-filter>
                <action android:name="android.intent.action.SEND" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="video/*" />
            </intent-filter>

            <!--TODO: Add this filter, if you want to support sharing multi videos into your app-->
            <intent-filter>
                <action android:name="android.intent.action.SEND_MULTIPLE" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="video/*" />
            </intent-filter>


            <!--TODO: Add this filter, if you want to support sharing any type of files-->
            <intent-filter>
                <action android:name="android.intent.action.SEND" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="*/*" />
            </intent-filter>

            <!--TODO: Add this filter, if you want to support sharing multiple files of any type-->
            <intent-filter>
                <action android:name="android.intent.action.SEND_MULTIPLE" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="*/*" />
            </intent-filter>
      </activity>

  </application>
</manifest>
```

## iOS
### App Groupsの追加
FlutterアプリとShare Extensionでデータを共有するためにApp Groupsの設定をします。
Xcodeで`Signing & Capabilities`の`Capability`からApp Groupsを追加します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/c26207e7-431b-a34f-dcc6-1c78bacbdbb9.png" width="400">



### ios/Runner/info.plistに追加

`AppGroupId`はアプリのBundleIdに`gruop.${アプリのBundleId}`なら追記しなくても良いです。

e.g.)
アプリのBundleId : `com.hoge.app`
GruopId : `gruop.com.hoge.app`

```

<!-- アプリのBundleIdに`gruop.${アプリのBundleId}`なら記載は不要 -->
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> 
<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeRole</key>
			<string>Editor</string>
			<key>CFBundleURLSchemes</key>
			<array>
				<string>SharingMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
			</array>
		</dict>
	</array>
```

### XcodeからShare Extensionを追加する

XcodeでFile/New/Target で Share Extension選択をします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/503475a7-d7c0-f695-2ba9-b0097f392a34.png" width="400">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/3f526d30-e53b-55b0-1a0e-38025023e34b.png" width="400">

Share Extensionを追加した後にそのまま実行すると、`Cycle inside Runner; building could produce unreliable results`になってしまいます。
これは`Embed Pods Frameworks`と`Thin Binary`のBuild Phasesは、Extensionのコピー作業を前提としているためです。
なので、`Embed Foundation Extensions`を`Embed Pods Frameworks`と`Thin Binary`よりも上に持っていく必要があります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/2ffc9b40-6f85-1580-cde5-00ed056b7fa1.png" width="400">


### Share ExtensionにもApp Groupsを追加する
アプリの`Signing & Capabilities`にApp Groupsを追加したように、Share Extensionの方にも追加します。


### ios/Share Extension/info.plistに追加
パッケージのドキュメントに記載してある通りに追記します。

[NSExtensionActivationRule](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule)の内容はアプリに必要な部分のみで良いです。


```
	<key>NSExtension</key>
    <dict>
    <key>NSExtensionAttributes</key>
    <dict>
        <key>PHSupportedMediaTypes</key>
        <array>
            <!-- To share video into your app-->
            <string>Video</string>
            <!-- To share images into your app-->
            <string>Image</string>
        </array>

        <key>NSExtensionActivationRule</key>
        <dict>
            <!-- To share text into your app -->
            <key>NSExtensionActivationSupportsText</key>
            <true/>
            <!-- TO share urls into your app -->
            <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
            <integer>1</integer>
            <!-- To share images into your app -->
            <key>NSExtensionActivationSupportsImageWithMaxCount</key>
            <integer>20</integer>
            <!-- To share video into your app -->
            <key>NSExtensionActivationSupportsMovieWithMaxCount</key>
            <integer>10</integer>
            <!-- To share other files into your app -->
            <key>NSExtensionActivationSupportsFileWithMaxCount</key>
            <integer>10</integer>
        </dict>
    </dict>
    <key>NSExtensionMainStoryboard</key>
    <string>MainInterface</string>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.share-services</string>
    </dict>
```

### ShareViewController

Share Extensionを追加した時に生成された`ShareViewController.swift`を[ドキュメント](https://pub.dev/packages/flutter_sharing_intent#ios)の通りに実装します。


### AppDelegate

AppDelegateも[ドキュメント](https://pub.dev/packages/flutter_sharing_intent#ios)の通りに実装します。

```swift
import UIKit
import Flutter
import flutter_sharing_intent


@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    // 追加部分
    override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {

     let sharingIntent = SwiftFlutterSharingIntentPlugin.instance
     /// if the url is made from SwiftFlutterSharingIntentPlugin then handle it with plugin [SwiftFlutterSharingIntentPlugin]
     print(url)
     if sharingIntent.hasSameSchemePrefix(url: url) {
         return sharingIntent.application(app, open: url, options: options)
     }

     // Proceed url handling for other Flutter libraries like uni_links
     return super.application(app, open: url, options:options)
    }   
}
```


## Flutter
Flutterは[ドキュメント](https://pub.dev/packages/flutter_sharing_intent#full-example)通りに実装しました。


```dart
import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late StreamSubscription _intentDataStreamSubscription;
  List<SharedFile>? list;

  @override
  void initState() {
    super.initState();
    // アプリがメモリ内にあるときにアプリ外から来た画像を共有する場合
    _intentDataStreamSubscription = FlutterSharingIntent.instance
        .getMediaStream()
        .listen((List<SharedFile> value) {
      setState(() {
        list = value;
      });
    }, onError: (err) {
      print('getIntentDataStream error: $err');
    });

    // アプリ終了中にアプリの外から来た画像を共有する場合
    FlutterSharingIntent.instance
        .getInitialSharing()
        .then((List<SharedFile> value) {
      setState(() {
        list = value;
      });
    });
  }

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

  @override
  Widget build(BuildContext context) {
    final sharingData = list?.firstOrNull?.value;

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Padding(
          padding: const EdgeInsets.all(8.0),
          child: sharingData != null
              ? Image.file(File(sharingData))
              : const SizedBox.shrink(),
        ),
      ),
    );
  }
}
```

# まとめ
今回、ハマった部分のPRが早くマージされることを祈ります。

# おわりに
最後までお読みいただきありがとうございます。
間違っている点などあればコメントにて指摘いただけますと幸いです。


アプリ設計や実装でお困りの際はお気楽にお問合せください。

https://neverjp.com/contact/