🦈

【Flutter 3.16】Share Extensionの画面をFlutterで作り、プラグインを呼ぶ

2024/02/04に公開

Overview

  • Flutter 3.16では、Flutterの画面をiOS App Extensionから呼び出したり、Dartのコードを読んだりできるようになりました。
  • この機能を用いて、Share ExtensionをFlutterの画面で開くものをを実装します
  • App Extension上のFlutterから、プラグインの機能を呼べるようにします

receive_sharing_intentをベースにしています。

App Extensionを作る

  1. Xcode上で、「File/New/Target」をから、「Share Extension」を選択します。
  2. ShareExtensionなど、適当な名前を設定します。(ここではShareExtension)
  3. deployment targetをRunner.appと一緒にします。
  4. ios/ShareExtension/Info.plistを下記のように設定します。画像や動画が不要な場合、適宜項目を削除します。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>AppGroupId</key>
    <string>$(CUSTOM_GROUP_ID)</string>
	<key>CFBundleVersion</key>
	<string>$(FLUTTER_BUILD_NUMBER)</string>
	<key>NSExtension</key>
	<dict>
		<key>NSExtensionAttributes</key>
        <dict>
            <key>PHSupportedMediaTypes</key>
               <array>
                   <string>Video</string>
                   <string>Image</string>
               </array>
            <key>NSExtensionActivationRule</key>
            <dict>
                <key>NSExtensionActivationSupportsText</key>
                <true/>
            	<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
            	<integer>1</integer>
                <key>NSExtensionActivationSupportsImageWithMaxCount</key>
                <integer>100</integer>
                <key>NSExtensionActivationSupportsMovieWithMaxCount</key>
                <integer>100</integer>
                <key>NSExtensionActivationSupportsFileWithMaxCount</key>
                <integer>1</integer>
            </dict>
        </dict>
		<key>NSExtensionMainStoryboard</key>
		<string>MainInterface</string>
		<key>NSExtensionPointIdentifier</key>
		<string>com.apple.share-services</string>
	</dict>
  </dict>
</plist>
  1. Runner/Info.plistに下記の項目を追加します。receive_sharing_intentではURLスキームの設定が必要でしたが、今回はApp Extensionのみなので、CFBundleURLTypesの指定は不要です。
	<key>AppGroupId</key>
	<string>$(CUSTOM_GROUP_ID)</string>
	<key>NSPhotoLibraryUsageDescription</key>
	<string>画像をアップロードするために、アルバムへの権限を許可してください。</string>
  1. Runner.entitlementsShareExtension.entitlementsを下記の要領で設定します。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.application-groups</key>
	<array>
		<string>group.(Bundle IDが入る)</string>
	</array>
</dict>
</plist>
  1. iOS/Podfilesを下記のように指定します。
target 'Runner' do
  use_frameworks!
  use_modular_headers!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))

  target 'ShareExtension' do
    inherit! :search_paths
  end
end
  1. Xcode上で、RunnerとShare Extensionの両方のSigning & CapabilitiesタブからApp GroupsのCapabilityを追加します。(+ Capabilityのところ)
  2. 両方のApp Groupsにinfo.(Bundle ID)を追加します。
  3. RunnerのBuild PhasesのTarget DependenciesShareExtensionを指定し、Embed Foundation ExtensionThin Binaryの上に来るようにします。Embed Foundation ExtensionにもShareExtension.appexを追加します。
  4. Share ExtensionのBuild Settingsで、PackagingDefines ModuleYesに、Swift Compiler - GeneralObjective-C Bridging HeaderShareExtension/ShareExtension-Bridging-Header.hに設定します。
  5. ShareExtensionのFrameworks and LibrariesでFlutter.xcframeworkを追加します。追加するのは<path_to_flutter_sdk>/bin/cache/artifacts/engine/ios/extension_safe/Flutter.xcframeworkですが、Profile, Releaseビルドの場合はiosの場所がios-profileios-releaseに変わります。これは自動では変わらないので、ビルドのたびに変更する必要があります。
  6. Flutter側では、下記のプラグインとパッケージを使用します。auto_routeは必須ではありませんが、実際に共有用の画面をアプリ本体と異なる画面で表示する場合には何かしらの画面遷移を管理するパッケージが入っているだろうということで、ここではauto_routeを例にします。
dependencies:
  auto_route: ^6.0.5
  shared_preference_app_group: ^1.0.0+1

dev_dependencies:
  auto_route_generator: ^6.0.0
  build_runner: ^2.3.3
  1. 上記のパッケージやプラグインが入っていることを前提に、ShareExtension-Bridging-Header.h, ShareExtensionPluginRegistrant.h, ShareExtensionPluginRegistrant.m, ShareViewController.swiftを追加し、下記のようにします。
ShareExtension-Bridging-Header.h
#import "ShareExtensionPluginRegistrant.h"
ShareExtensionPluginRegistrant.h
#ifndef ShareExtensionPluginRegistrant_h
#define ShareExtensionPluginRegistrant_h

#import <Flutter/Flutter.h>

@interface ShareExtensionPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end

#endif /* ShareExtensionPluginRegistrant_h */
ShareExtensionPluginRegistrant.m
#import <Foundation/Foundation.h>
#import "ShareExtensionPluginRegistrant.h"

#if __has_include(<shared_preference_app_group/SharedPreferenceAppGroupPlugin.h>)
#import <shared_preference_app_group/SharedPreferenceAppGroupPlugin.h>
#else
@import shared_preference_app_group;
#endif

@implementation ShareExtensionPluginRegistrant

+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
    
    [SharedPreferenceAppGroupPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferenceAppGroupPlugin"]];
}
@end
  1. ここでは話を簡単にするために、SwiftViewController.swiftの内容は下記のようにします。とりあえずShare Extension上でFlutterのViewを作成します。FlutterViewControllerの引数initialRouteは後に説明します。
import UIKit
import Flutter

class ShareViewController: UIViewController, FlutterPluginRegistry {
    
    private var flutterViewController: FlutterViewController = FlutterViewController(project: nil, initialRoute: "/share-extension", nibName: nil, bundle: nil)
    
    @objc func registrar(forPlugin pluginKey: String) -> FlutterPluginRegistrar? {
        return flutterViewController.registrar(forPlugin: pluginKey)
    }
    
    @objc func hasPlugin(_ pluginKey: String) -> Bool {
        return flutterViewController.hasPlugin(pluginKey)
    }
    
    @objc func valuePublished(byPlugin pluginKey: String) -> NSObject? {
        return flutterViewController.valuePublished(byPlugin: pluginKey)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        ShareExtensionPluginRegistrant.register(with: self)
        showFlutter()
    }

    func showFlutter() {
        addChild(flutterViewController)
        view.addSubview(flutterViewController.view)
        flutterViewController.view.frame = view.bounds
    }
}

これでたぶんiOSネイティブの実装はいったん終わりです。次にFlutterへ移りましょう。

Flutter側で受信する

一例として、下記のようなページのウィジェットを作成します。別途本体ではSharedPreferenceAppGroup.setで何かしらのキーを指定し、保存しているものとします。

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:shared_preference_app_group/shared_preference_app_group.dart';

()
class ShareExtensionPage extends StatelessWidget {
  const ShareExtensionPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Share Extension Page Test"),
      ),
      body: FutureBuilder(
        future: Future(() async {
          await SharedPreferenceAppGroup.setAppGroup(
              "group.(Bundle Id)");
          return await SharedPreferenceAppGroup.get("(本体側で保持したキー)")
              as String?;
        }),
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return Text(snapshot.requireData ?? "no data");
          } else {
            return const CircularProgressIndicator();
          }
        },
      ),
    );
  }
}

続いてauto_routeのルート定義を更新します。SplashRoute以下はアプリを通常起動したときの遷移として、initial: trueを指定しています。しかしApp Extensionでも通常起動したときと同じ遷移では困りますので、上記のSwift側で指定したinitialRouteと同じパス名をこちらでも指定します。こうすることで、App Extensionから起動したときはinitial: trueよりFlutterViewControllerの引数が優先されることになります。

part 'app_router.gr.dart';

()
class AppRouter extends _$AppRouter {
  
  final List<AutoRoute> routes = [
    AutoRoute(page: SplashRoute.page, initial: true),
    ...
    
    AutoRoute(path: "/share-extension", page: ShareExtensionRoute.page)
  ];
}

Flutter側で共有した内容を取得できるようにする

この部分は大いにreceive_sharing_intentを参考にしました。

ios/ShareExtension/ShareViewController.swift
import UIKit
import Flutter
import Social
import MobileCoreServices
import Photos

class ShareViewController: UIViewController, FlutterPluginRegistry {
    private let imageContentType = kUTTypeImage as String
    private let videoContentType = kUTTypeMovie as String
    private let textContentType = kUTTypeText as String
    private let urlContentType = kUTTypeURL as String
    private let fileURLType = kUTTypeFileURL as String;
    private let sharedKey = "ShareKey"
    private let hostAppBundleIdentifier = "info.shiosyakeyakini.miria"
    
    private var shareText: [String] = []
    private var shareFiles: [SharedMediaFile] = []
    
    private let flutterViewController: FlutterViewController = FlutterViewController(project: nil, initialRoute: "/share-extension", nibName: nil, bundle: nil)
    
    
    @objc func registrar(forPlugin pluginKey: String) -> FlutterPluginRegistrar? {
        return flutterViewController.registrar(forPlugin: pluginKey)
    }
    
    @objc func hasPlugin(_ pluginKey: String) -> Bool {
        return flutterViewController.hasPlugin(pluginKey)
    }
    
    @objc func valuePublished(byPlugin pluginKey: String) -> NSObject? {
        return flutterViewController.valuePublished(byPlugin: pluginKey)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 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(imageContentType) {
                        handleImages(content: content, attachment: attachment, index: index)
                    } else if attachment.hasItemConformingToTypeIdentifier(textContentType) {
                        handleText(content: content, attachment: attachment, index: index)
                    } else if attachment.hasItemConformingToTypeIdentifier(fileURLType) {
                        handleFiles(content: content, attachment: attachment, index: index)
                    } else if attachment.hasItemConformingToTypeIdentifier(urlContentType) {
                        let attributedTitle = content.attributedTitle?.string
                        let attributedContent = content.attributedContentText?.string
                        handleUrl(content: content, attachment: attachment, index: index, title: attributedTitle ?? attributedContent);
                    } else if attachment.hasItemConformingToTypeIdentifier(videoContentType) {
                        handleVideos(content: content, attachment: attachment, index: index)
                    }
                }

            }
        }
        ShareExtensionPluginRegistrant.register(with: self)
        
        addChild(flutterViewController)
        view.addSubview(flutterViewController.view)
        flutterViewController.view.frame = view.bounds
    }
    
    private func save() {
        let userDefaults = UserDefaults(suiteName: "group.\(self.hostAppBundleIdentifier)")
        let encoder = JSONEncoder()
        let json = try? encoder.encode(SendData(text: self.shareText, files: self.shareFiles))
        guard let encoded = json else {
            return
        }
        userDefaults?.set(String(data: encoded, encoding: .utf8), forKey: self.sharedKey)
        userDefaults?.synchronize()
    }
    
    private func handleText (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
        attachment.loadItem(forTypeIdentifier: textContentType, options: nil) { [weak self] data, error in

            if error == nil, let item = data as? String, let this = self {
                this.shareText.append(item)
                if index == (content.attachments?.count)! - 1 {
                    this.save()
                }
            }
        }
    }

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

            if error == nil, let item = data as? URL, let this = self {
                let text: String
                if(title != nil) {
                    text = "[\(title ?? "")](\(item.absoluteString))"
                } else {
                    text = item.absoluteString
                }
                this.shareText.append(text)
                if index == (content.attachments?.count)! - 1 {
                    this.save()
                }
            }
        }
    }

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

            if error == nil, let url = data as? URL, let this = self {

                // Always copy
                let fileName = this.getFileName(from: url, type: .image)
                let newPath = FileManager.default
                    .containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
                    .appendingPathComponent(fileName)
                let copied = this.copyFile(at: url, to: newPath)
                if(copied) {
                    this.shareFiles.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .image))
                }
                if index == (content.attachments?.count)! - 1 {
                    this.save()
                }
            }
        }
    }

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

            if error == nil, let url = data as? URL, let this = self {

                // Always copy
                let fileName = this.getFileName(from: url, type: .video)
                let newPath = FileManager.default
                    .containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
                    .appendingPathComponent(fileName)
                let copied = this.copyFile(at: url, to: newPath)
                if(copied) {
                    guard let sharedFile = this.getSharedMediaFile(forVideo: newPath) else {
                        return
                    }
                    this.shareFiles.append(sharedFile)
                }
                if index == (content.attachments?.count)! - 1 {
                    this.save()
                }
            }
        }
    }

    private func handleFiles (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
        attachment.loadItem(forTypeIdentifier: fileURLType, options: nil) { [weak self] data, error in
            if error == nil, let url = data as? URL, let this = self {
                // Always copy
                let fileName = this.getFileName(from :url, type: .file)
                let newPath = FileManager.default
                    .containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
                    .appendingPathComponent(fileName)
                let copied = this.copyFile(at: url, to: newPath)
                if (copied) {
                    this.shareFiles.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .file))
                }
                if index == (content.attachments?.count)! - 1 {
                    this.save()
                }
            }
        }
    }
    func getExtension(from url: URL, type: SharedMediaType) -> String {
        let parts = url.lastPathComponent.components(separatedBy: ".")
        var ex: String? = nil
        if (parts.count > 1) {
            ex = parts.last
        }

        if (ex == nil) {
            switch type {
                case .image:
                    ex = "PNG"
                case .video:
                    ex = "MP4"
                case .file:
                    ex = "TXT"
            }
        }
        return ex ?? "Unknown"
    }

    func getFileName(from url: URL, type: SharedMediaType) -> String {
        var name = url.lastPathComponent

        if (name.isEmpty) {
            name = UUID().uuidString + "." + getExtension(from: url, type: type)
        }

        return name
    }

    func copyFile(at srcURL: URL, to dstURL: URL) -> Bool {
        do {
            if FileManager.default.fileExists(atPath: dstURL.path) {
                try FileManager.default.removeItem(at: dstURL)
            }
            try FileManager.default.copyItem(at: srcURL, to: dstURL)
        } catch (let error) {
            print("Cannot copy item at \(srcURL) to \(dstURL): \(error)")
            return false
        }
        return true
    }

    private func getSharedMediaFile(forVideo: URL) -> SharedMediaFile? {
        let asset = AVAsset(url: forVideo)
        let duration = (CMTimeGetSeconds(asset.duration) * 1000).rounded()
        let thumbnailPath = getThumbnailPath(for: forVideo)

        if FileManager.default.fileExists(atPath: thumbnailPath.path) {
            return SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video)
        }

        var saved = false
        let assetImgGenerate = AVAssetImageGenerator(asset: asset)
        assetImgGenerate.appliesPreferredTrackTransform = true
        //        let scale = UIScreen.main.scale
        assetImgGenerate.maximumSize =  CGSize(width: 360, height: 360)
        do {
            let img = try assetImgGenerate.copyCGImage(at: CMTimeMakeWithSeconds(600, preferredTimescale: Int32(1.0)), actualTime: nil)
            try UIImage.pngData(UIImage(cgImage: img))()?.write(to: thumbnailPath)
            saved = true
        } catch {
            saved = false
        }

        return saved ? SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video) : nil

    }

    private func getThumbnailPath(for url: URL) -> URL {
        let fileName = Data(url.lastPathComponent.utf8).base64EncodedString().replacingOccurrences(of: "==", with: "")
        let path = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: "group.\(hostAppBundleIdentifier)")!
            .appendingPathComponent("\(fileName).jpg")
        return path
    }


    class SharedMediaFile: Codable {
        var path: String; // can be image, video or url path. It can also be text content
        var thumbnail: String?; // video thumbnail
        var duration: Double?; // video duration in milliseconds
        var type: SharedMediaType;


        init(path: String, thumbnail: String?, duration: Double?, type: SharedMediaType) {
            self.path = path
            self.thumbnail = thumbnail
            self.duration = duration
            self.type = type
        }

        // Debug method to print out SharedMediaFile details in the console
        func toString() {
            print("[SharedMediaFile] \n\tpath: \(self.path)\n\tthumbnail: \(self.thumbnail)\n\tduration: \(self.duration)\n\ttype: \(self.type)")
        }
    }
    
    struct SendData: Encodable {
        let text: [String]
        let files: [SharedMediaFile]
    }

    enum SharedMediaType: Int, Codable {
        case image
        case video
        case file
    }

    func toData(data: [SharedMediaFile]) -> Data {
        let encodedData = try? JSONEncoder().encode(data)
        return encodedData!
    }
}

これでFlutter側で受け取ってみます。

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preference_app_group/shared_preference_app_group.dart';

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

  
  State<StatefulWidget> createState() =>
      ShareExtensionPageState();
}

class ShareExtensionPageState extends State<ShareExtensionPage> {
  var sharedPreference = "";
  var sharedData = "";
  String? error;

  
  void didChangeDependencies() {
    super.didChangeDependencies();
    Future(() async {
      try {
        await SharedPreferenceAppGroup.setAppGroup(
            "group.(Bundle ID)");
        sharedPreference =
            await SharedPreferenceAppGroup.get("account_settings") as String? ??
                "";
        sharedData =
            await SharedPreferenceAppGroup.get("ShareKey") as String? ?? "";
        await SharedPreferenceAppGroup.setString("ShareKey", "");
        setState(() {});
      } catch (e) {
        setState(() {
          error = e.toString();
        });
      }
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Share Extension Page Test"),
      ),
      body: Text(error ??
          "SharedPreference: $sharedPreference\n共有されたデータ: $sharedData"),
    );
  }
}

かなり大変でしたが、これでひとまず「共有したものをFlutterでごにょごにょする」ところまでできました。

Share Extensionを閉じる

閉じる手段がない!ことに気が付きました。dart:uiにはexit(0)メソッドがありますが、これは強制終了なのでUX的によろしくなく、SystemNavigator.popはShareExtensionではうまく機能しませんでした。

そこでMethodChannelを用いてこれを閉じるネイティブコードを作成します。

まずはios/ShareExtension/ShareViewController.swiftのviewDidLoadでMethodChannelを登録します。ここで呼び出す処理はself.extensionContext!.completeRequest(returningItems: nil)で、これがExtensionの終了メソッドとなります。

ios/ShareExtension/ShareViewController.swift
        let shareExtensionChannel = FlutterMethodChannel(name: "(メソッドチャンネル名)", binaryMessenger: flutterViewController as! FlutterBinaryMessenger)
        shareExtensionChannel.setMethodCallHandler({
            (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
            switch call.method {
            case "exit":
                self.extensionContext!.completeRequest(returningItems: nil)
            default:
                result(FlutterMethodNotImplemented)
            }
            
            })
        
        ShareExtensionPluginRegistrant.register(withRegistry: self)

そしてこれを任意のタイミングでFlutter側で

  // メソッドチャンネルを定義
  static const shareExtensionMethodChannel =
      MethodChannel("(メソッドチャンネル)");

  // ネイティブの終了処理を呼ぶ
  shareExtensionMethodChannel.invokeMethod("exit");

とすることで、Flutter側で終了するタイミングを制御できるようになりました。

さいごに

Miriaさんでは、receive_sharing_intentからの移行というかたちでこれを行いました。この部分についてのコミットは下記のものです。

Discussion