Add an iOS Action Extension to your Flutter app

Add an iOS Action Extension to your Flutter app

Enhance your Flutter app with an iOS Action Extension

When building an application, you often want to simplify your users' life by providing some shortcuts to access some features. It can be, in the case of a messaging app, to send content to a contact from another app on the user's device without making the user leave that app.

What are Action extensions

Action Extensions are custom actions that are added to the share sheet to invoke your application from any other app. If you don't know what the share sheet is, it is the panel that appears when a user taps the share button within a running app.

Share sheet showed from safari

When you create an Action Extension, you have to declare the type of content it accepts. Your extension will then appear in the share sheet each time content matching the accepted type is shared.

Additional information about Action extensions is provided in the links section. In this article, we will focus on the technical part of how to add an action extension to a Flutter application.

Let's build

I want to keep things simple. We will make an application that will simply expose an Action Extension to receive a photo, and display this photo in the Flutter application
Please note that to understand what we are going to do in the following, you need to have some knowledge of native iOS development, especially of concepts like app security app groups, UserDefaults, UIKit etc...

For now, let's just create an empty Flutter app. To do so, run the Flutter create command:

Let's add the Extension

Creating the extension

Ok, we're good; we've created the Flutter app. Let's now open the ios project in XCode.

PS: Make sure to open the Runner.xcworkspace

Go to File -> New -> Target menu action, and select "Action Extension" in the panel :

Once this is done, a new folder will be added to your project :

This folder contains :

  • ActionViewController: It is responsible for presenting the interface and handling user interactions within the Action Extension. It can display custom views, buttons, or other UI elements to facilitate the desired action. The content selected by the user in the host app is typically passed to the Action Extension, allowing it to manipulate or process that data

  • MainInterface.storyboard: Used to design and define the user interface (UI) of the action extension

  • Info.plist: This serves a similar purpose as in a regular iOS app. It contains configuration settings and metadata specific to the Action Extension

The next step is to change the display name. This step is important because that name will be displayed beneath the app icon in the share sheet.

To do this, click on Runner -> [Action Extension] under the targets section:

By default, the project created contains code to display an image from the extension context.

The extension context is an instance of the NSExtensionContext class, which provides various methods and properties for communication and interaction between the extension and its host app. It serves as a bridge between the extension and the host app, facilitating data sharing and coordination.

In our Info.plist, let's modify the NSExtensionActivationRule under NSExtension -> NSExtensionAttributes to let our extension appear only when an image is shared:

SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" ) ).@count > 0 ).@count >

Testing the extension

To test our extension, let's go to XCode, then select our extension scheme; then run it as you would run an iOS app.

A window will show up, we will then have to select an app to run. That will be the app from which we will share the content to trigger the extension. We will select the Photos app, then click the Run button.

The Photos app will launch.

Sharing the content

To share the content with the host app, we need to achieve a few steps:

  • Let's add our app and extension under the same group. For each target, select it on the right panel, then go to the Signing & Capabilities tab and add the App Groups capability. Add a new group and name it group.YOUR_HOST_APP_BUNDLE_IDENTIFIER in this case group.com.example.actionExtensionExample

  • In the Info.plist file of both our targets, let's add a new entry named AppGroupId with the value $(CUSTOM_GROUP_ID)

  • Let's go back and add a new User-Defined setting in our target's build settings tab:

  • The next step is to add a custom scheme to our host application. This step is important because we will use a deep link to open our host app. Here is the code to add to the Info.plist
...
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>ImportMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
        </array>
    </dict>
    <dict/>
</array>
...

The final file content can be found here: https://github.com/stevenosse/articles_sample_apps/blob/main/action_extension_example/ios/Runner/Info.plist

We're going to edit the sample code so it fits our needs.

Now, we want to open our Flutter app when the user taps the "Done" button and display that image there.

@IBAction func done() {
    guard let url = self.imageURL else {
        self.extensionContext!.completeRequest(returningItems: self.extensionContext!.inputItems, completionHandler: nil)
        return
    }
    let fileName = "\(url.deletingPathExtension().lastPathComponent).\(url.pathExtension)"
    let newPath = FileManager.default
        .containerURL(forSecurityApplicationGroupIdentifier: self.appGroupId)!
        .appendingPathComponent(fileName)
    let copied = self.copyFile(at: url, to: newPath)
    if (copied) {
        let sharedFile = ImportedFile(
            path: newPath.absoluteString,
            name: fileName
        )
        self.importedMedia.append(sharedFile)
    }

    let userDefaults = UserDefaults(suiteName: self.appGroupId)
    userDefaults?.set(self.toData(data: self.importedMedia), forKey: self.sharedKey)
    userDefaults?.synchronize()
    self.redirectToHostApp()
}

So, when the "Done" button is tapped, we copy the files from the extension context into the application group container. Then we add an entry to the UserDefaults to keep track of the saved data.

The full file can be found here: ActionViewController.swift

Receiving the content

In our AppDelegate.swift, we need to add a new constructor. This one will be called when the app is opened through a deep link:

override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    self.initChannels(controller: controller)

    if (self.hasMatchingSchemePrefix(url: url)) {
        return self.handleUrl(url: url, setInitialData: false)
    }

    return super.application(app, open: url, options:options)
}

Here we are achieving a few steps:

  • We initiate the event channel

  • We check the conformity with the received URL and handle it when a match is found.

  • Our handleUrl will get our previously saved data from UserDefaults and send them through our EventChannel

public func handleUrl(url: URL?, setInitialData: Bool) -> Bool {
    if let url = url {
        let appDomain = Bundle.main.bundleIdentifier!
        let appGroupId = (Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String) ?? "group.\(Bundle.main.bundleIdentifier!)"

        let userDefaults = UserDefaults(suiteName: appGroupId)

        if let key = url.host?.components(separatedBy: "=").last,
            let json = userDefaults?.object(forKey: key) as? Data {
            let sharedArray = decode(data: json)
            let sharedMediaFiles: [ImportedFile] = sharedArray.compactMap {
                guard let path = getAbsolutePath(for: $0.path) else {
                    return nil
                }

                return ImportedFile.init(
                    path: path,
                    name: $0.name
                )
            }

            latestMedia = sharedMediaFiles
            if(setInitialData) {
                initialMedia = latestMedia
            }

            self.eventSinkMedia?(toJson(data: latestMedia))
        }
        return true
    }
    latestMedia = nil
    return false
}

In our Flutter app, we need to create an EventChannel that will receive the shared content (sent through the event channel we declared in the AppDelegate). Here is the code to do that:

import 'dart:async';
import 'dart:convert';

import 'package:flutter/services.dart';

class ImportFileChannel {
  static const String _eventChannelName = 'com.example.actionExtensionExample/import';

  static const EventChannel _eventChannel = EventChannel(_eventChannelName);

  Stream<List<ImportedFile>> getMediaStream() {
    final stream = _eventChannel.receiveBroadcastStream("media").cast<String?>();
    Stream<List<ImportedFile>> sharedMediaStream = stream.transform<List<ImportedFile>>(
      StreamTransformer<String?, List<ImportedFile>>.fromHandlers(
        handleData: (String? data, EventSink<List<ImportedFile>> sink) {
          if (data == null) {
            sink.add([]);
            return;
          }

          final List<ImportedFile> args = (json.decode(data) as List<dynamic>).map((e) {
            return ImportedFile.fromJson(e);
          }).toList();
          sink.add(args);
        },
      ),
    );
    return sharedMediaStream;
  }
}

class ImportedFile {
  final String path;
  final String name;

  ImportedFile({required this.name, required this.path});

  factory ImportedFile.fromJson(Map<String, dynamic> json) {
    return ImportedFile(
      name: json['name'],
      path: json['path'],
    );
  }
}

That's it!

Wrapping up

We've added an action extension to our Flutter application. The extension allows a user to preview the shared content, then imports the image to our host Flutter app and display it there.
To achieve this, here are the steps we followed:

  • When the extension is selected from the share sheet, we display the selected image in our extension view

  • When the users tap the "Done" button, we copy the shared file into the Group container (which is shared by both the App and the extension) and add an entry to the UserDefaults

  • Then we redirect to the host app. To achieve this, we use a deep link.

  • The host app then receives the deep link and sends the shared data (from the shared UserDefaults) to the Flutter app through an Event Channel

  • The Flutter app can read the shared data from the EventChannel and display the image from the payload

The full of this article can be found here: action extension example

Useful links:

Did you find this article valuable?

Support Steve Nosse by becoming a sponsor. Any amount is appreciated!