2023. 6. 6. 18:53ㆍ모바일어플개발/Flutter
안녕하세요~ totally 개발자입니다.
receive_sharing_intent
다른 앱 및 웹에서 파일 및 URL를 가져오는 경우에 receive_sharing_intent 패키지를 사용하여 구현할 수 있습니다.
Step 1: pubspec.yaml에 receive_sharing_intent 패키지를 추가합니다. 현재 기준으로 최신 버전은 1.4.5입니다.
Step 2: 안드로이드는 AndroidManifest.xml로 ios는 info.plist로 가셔서 아래 해당 부분을 넣어줍니다.
[안드로이드]
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
.....
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:name="io.flutter.app.FlutterApplication"
...
>
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!--TODO: Add this filter, if you want support opening urls into your app-->
<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>
<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>
<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>
<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]
...
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia</string>
</array>
</dict>
<dict/>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>To upload photos, please allow permission to access your photo library.</string>
...
그리고 안드로이드의 경우에는 바로 아래에 코드를 MainActivity.kt에 넣어주시면 됩니다.
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
・・・
if (intent.getIntExtra("org.chromium.chrome.extra.TASK_ID", -1) == this.taskId) {
this.finish()
intent.addFlags(FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
super.onCreate(savedInstanceState)
}
}
r
Step 3: XCode를 열어줍니다. VSCode 등 에디터의 터미널에서 open ios/Runner.xcworkspace로도 열 수 있습니다. 그 후 File -> New -> Target을 선택합니다. (안드로이드 구현하시는 분은 Step 9로 가셔서 진행하시면 됩니다.)
Step 4: Share Extension을 선택하신 뒤 Next를 클릭합니다. 그 후 product name은 Share Extension으로 입력 후, team 선택 후 넘어가시면 됩니다.
Step 5: Runner와 Share Extensiond의 Deployment target 버전을 일치시켜 줍니다.
Step 6: Share Extension 경로에 있는 info.plist에 다음 내용을 추가합니다.
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>PHSupportedMediaTypes</key>
<array>
<!--TODO: Add this flag, if you want to support sharing video into your app-->
<string>Video</string>
<!--TODO: Add this flag, if you want to support sharing images into your app-->
<string>Image</string>
</array>
<key>NSExtensionActivationRule</key>
<dict>
<!--TODO: Add this flag, if you want to support sharing text into your app-->
<key>NSExtensionActivationSupportsText</key>
<true/>
<!--TODO: Add this tag, if you want to support sharing urls into your app-->
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<!--TODO: Add this flag, if you want to support sharing images into your app-->
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>100</integer>
<!--TODO: Add this flag, if you want to support sharing video into your app-->
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>100</integer>
<!--TODO: Add this flag, if you want to support sharing other files into your app-->
<!--Change the integer to however many files you want to be able to share at a time-->
<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> 짝이 안 맞는 경우 여기에서 </dict>를 40번째 줄과 41번째 줄 사이에 넣어주시면 됩니다.
Step 7: ShareViewController.swift에 다음을 넣고 8번째 줄에 hostAppBundleIdentifier를 해당 패키지명으로 변경해주시면 됩니다.
import UIKit
import Social
import MobileCoreServices
import Photos
class ShareViewController: SLComposeServiceViewController {
// TODO: IMPORTANT: This should be your host app bundle identifier
let hostAppBundleIdentifier = "com.kasem.sharing"
let sharedKey = "ShareKey"
var sharedMedia: [SharedMediaFile] = []
var sharedText: [String] = []
let imageContentType = kUTTypeImage as String
let videoContentType = kUTTypeMovie as String
let textContentType = kUTTypeText as String
let urlContentType = kUTTypeURL as String
let fileURLType = kUTTypeFileURL as String;
override func isContentValid() -> Bool {
return true
}
override func viewDidLoad() {
super.viewDidLoad();
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// 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) {
handleUrl(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(videoContentType) {
handleVideos(content: content, attachment: attachment, index: index)
}
}
}
}
}
override func didSelectPost() {
print("didSelectPost");
}
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 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.sharedText.append(item)
// 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 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 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.sharedMedia.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .image))
}
// 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.toData(data: this.sharedMedia), forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .media)
}
} else {
self?.dismissWithError()
}
}
}
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.sharedMedia.append(sharedFile)
}
// 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.toData(data: this.sharedMedia), forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .media)
}
} else {
self?.dismissWithError()
}
}
}
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.sharedMedia.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .file))
}
if index == (content.attachments?.count)! - 1 {
let userDefaults = UserDefaults(suiteName: "group.\(this.hostAppBundleIdentifier)")
userDefaults?.set(this.toData(data: this.sharedMedia), forKey: this.sharedKey)
userDefaults?.synchronize()
this.redirectToHostApp(type: .file)
}
} 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)
}
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)
}
enum RedirectType {
case media
case text
case file
}
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)")
}
}
enum SharedMediaType: Int, Codable {
case image
case video
case file
}
func toData(data: [SharedMediaFile]) -> Data {
let encodedData = try? JSONEncoder().encode(data)
return encodedData!
}
}
extension Array {
subscript (safe index: UInt) -> Element? {
return Int(index) < count ? self[Int(index)] : nil
}
}
Step 8: app group를 추가합니다.
Runner, Share Extension 동일하게 해주시면 됩니다.
Step 9: 그 다음 아래 코드를 그대로 복사하여 넣어줍니다.
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 ?? "")
],
),
),
),
);
}
}
Step 10: 구현해본 모습입니다. 저는 사파리 브라우저에서 flutter.dev 웹사이트를 여기로 공유해보았습니다. Shared urls에 잘 표시되는 것을 확인해볼 수 있습니다.
References:
https://pub.dev/packages/receive_sharing_intent