Salesforce MobilePush SDK を Flutter に組み込んでみた

※ これから記載する事項は、私が所属する会社とは一切関係のない事柄です。

---

[更新: 2024-04-24]
Marketing Cloud 側で Flutter SDK がリリースされたので、今後はそちらをご利用いただけるようになりました🎉

https://github.com/salesforce-marketingcloud/sdk-flutter-plugin

そのため、本記事はMethocChannelを利用して、Flutter対応していないSDKをAndroid/iOSに組み込む際の参考としていただけると良いかと思います。

---

Salesforce の [Marketing Cloud Mobile Push](https://help.salesforce.com/s/articleView?id=sf.mc_mp_get_started.htm&type=5) はアプリプッシュやアプリ内メッセージの配信などを実現することのできるソリューションの一つです。コンタクトの登録やメッセージの受信をアプリで行うためには、SDKの組み込みが必要となります。現時点(2023年10月現在)では、Mobile SDKはFlutterを公式にサポートしていないため、組み込む場合はカスタム実装となるため、テクニカルサポートの対象外となる点は注意が必要です。
この記事では、Flutterで書かれたアプリケーションにMarketing Cloud Mobile SDKを組み込む手順をご紹介します。

## Flutter と ネイティブコードの連携
ネイティブのSDKをFlutterから利用する場合、Flutterの提供している [Flutter MethodChannel](https://api.flutter.dev/flutter/services/MethodChannel-class.html) を利用する必要があります。その他に、プラットフォーム固有の処理や、デバイスのAPIにアクセスする場合もMethodChannelを利用することでFlutterは柔軟な実装を可能にしています。またMethodChannel以外にもEventChannelやBasicMessageChannelなど、異なるユースケースに適したクラスがFlutterには存在します。

MethodChannelを使用すると、Dartコードからプラットフォームのメソッドを呼び出し、その結果をDartに戻すことができます。また、ネイティブコードからDartコードへの非同期のコールバックやイベントもハンドリングすることが可能です。実際に、プラットフォームとの連携をするFlutterのパッケージはこのメソッドが使われています。

前置きが長くなりましたが、この記事ではDartからパラメータをMethodChannel経由でプラットフォームに渡して、SDKのメソッドを実行し必要があれば結果をDartに返却するという処理を行います。

## 前提条件
- Salesforce Marketing Cloud Engagmentアカウント
- Flutter開発環境
- Android StudioまたはXCode

## ステップ1: Marketing Cloud Mobile Pushでアプリを登録
アプリとMarketing Cloudを連携するためには、まずMarketing Cloud側でアプリを登録しておく必要があります。このプロセスでは、バンドル名(パッケージ名)やFirebaseトークン、もしくはプッシュ通知証明書の登録をします。これらの情報を登録することで、アプリID等の値が発行され、後ほどSDKの設定で使用されます。
[参考ドキュメント](https://help.salesforce.com/s/articleView?id=sf.mc_mp_provisioning_info.htm&type=5)

## ステップ2: SDKのインストール
Flutterプロジェクトのiosとandroidディレクトリ下で、それぞれSDKをインストールします。ここまではネイティブのSDKを組み込みステップと同じと考えていいと思います。
[Android](https://salesforce-marketingcloud.github.io/MarketingCloudSDK-Android/sdk-implementation/implement-sdk-google.html)
[iOS1](https://salesforce-marketingcloud.github.io/MarketingCloudSDK-iOS/get-started/get-started-migration.html), [iOS2](https://salesforce-marketingcloud.github.io/MarketingCloudSDK-iOS/get-started/get-started-addsdk.html)

## ステップ3: SDKの起動
アプリケーションが起動した際に、ステップ1で取得したアプリケーションID等をFlutterからプラットフォームに連携し、Android/iOSでSDKの初期化処理を実行します。

### ステップ3-1: DartでMethodChannelを設定し、Flutter→プラットフォームにデータ連携をする

main.dartファイルを開き、MethodChannelをのインスタンスを作成します。引数に渡す値は任意の文字列ですが、アプリ内で固有の文字列となるように設定します。このあと紹介しますが、同じ文字列をネイティブコードにも設定することで連携の準備ができる形となります。

```dart
const MethodChannel _channel = MethodChannel('com.example.mce_app/mce');
```
※依存ファイルは適宜インポートします

次に、下記のメソッドをmain.dartに追加します。この処理ではネイティブコードで、SDKの初期化を実行するためのパラメータを渡しています。

```dart
Future<dynamic> initMCESDK() async {
  const accessToken = "***";
  const appId = "***";
  const appEndpoint = "https://***.marketingcloudapis.com/";
  const senderId = "***";
  final Map configParams = {
    'accessToken': accessToken,
    'appId': appId,
    'appEndpoint': appEndpoint,
    'senderId': senderId,
  };
  try {
    // ⓵
    final initResult = await _channel.invokeMethod('initMCESDK', configParams);
    // ②
    return initResult;
  } catch (e) {
    print("Error calling initMCESDK: $e");
    if (e is PlatformException) {
      print("Error code: ${e.code}");
    }
    return "error";
  }
}
```
※実際はappIdなどのクレデンシャル情報はflutter_dotenvなどを利用して設定します。ここでは、Flutterを経由した値を使って、SDKの初期化を実行しています。ネイティブコード側で直接実行する方法でも可能です。

上記のコードが実行されると、Dartからネイティブのコードの関数を非同期で呼び出し、その結果を取得することができます。
__①__ `_channel`(= 前のステップで作成したMethodChannelのインスタンス) が `invokeMethod` を実行することで、ネイティブ側では、第一引数のメソッド名と第二引数のペイロードを使用して、メソッドが実行されます。第一引数の `initMCESDK` はネイティブコード側で実装されているメソッド名を指定しています（ネイティブコードの実装はこのあと紹介します）。
__②__ 実行結果をreturnしています。結果をもとにFlutter側で処理を行う必要がある際は、返り値の設計も必要になります。

さらに、この状態だとアノニマスなユーザーが生成されますが、ここではユーザーIDをDartからネイティブコードに渡して、Knownユーザーとしてコンタクトを登録してみます。

下記のメソッドをmain.dartに追加します。

```dart
Future<dynamic> registerUserId(String userId) async {
  final Map userParams = {
    "userId": userId,
  };
  try {
    final registerResult = await _channel.invokeMethod('registerUserId', userParams);
    return registerResult;
  } catch (e) {
    print("Error calling registerUserId: $e");
    if (e is PlatformException) {
      print("Error code: ${e.code}");
    }
    return "error";
  }
}
```

アプリが立ち上がったタイミングで初期化の実行、ボタンをタップした時にユーザー登録の処理を行うように修正します。

```dart
// SDK初期化の処理をアプリが立ち上がったタイミングで実行
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final result = await initMCESDK();
  print(result);
  runApp(const MyApp());
}
```

```dart
// ユーザー登録の処理をボタンをタップ
class _MyHomePageState extends State<MyHomePage> {
  bool isRegistered = false;
  String message = 'You have not registered to the app.';

  void _updateRegistration() async {
    setState(() {
      isRegistered = true;
      message = 'Thank you for your registration!';
    });

    final userId = 'new-user-001-flutter-android';
    final result = await registerUserId(userId);
    print(result);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            MaterialButton(
              color: Colors.blue,
              textColor: Colors.white,
              onPressed: _updateRegistration,
              child: Text(message),
            ),
          ],
        ),
      ),
    );
  }
}
```


### ステップ3-2 (Kotlinでの実装)

まずFlutterプロジェクトを作成すると、`android/app/src/main/kotlin/[パッケージ名]/MainActivity.kt`には下記のソースコードが記述されていると思います。

```kotlin
package com.example.mce_app

import io.flutter.embedding.android.FlutterActivity

class MainActivity: FlutterActivity() {}
```

MainActivityの中身を以下のように修正します。

```kotlin
class MainActivity: FlutterActivity() {
  companion object { // ⓵
    private const val CHANNEL = "com.example.mce_app/mce"
    private const val METHOD_INIT_MCE_SDK = "initMCESDK"
    private const val METHOD_REGISTER_USER = "registerUserId"
  }

  private lateinit var channel: MethodChannel

  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)

    // ②
    channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
    channel.setMethodCallHandler { methodCall: MethodCall, result: MethodChannel.Result ->
      when (methodCall.method) {
        METHOD_INIT_MCE_SDK -> {
          // ③
          val accessToken = methodCall.argument<String>("accessToken") ?: ""
          val appId = methodCall.argument<String>("appId") ?: ""
          val appEndpoint = methodCall.argument<String>("appEndpoint") ?: ""
          val senderId = methodCall.argument<String>("senderId") ?: ""

          if (BuildConfig.DEBUG) {
            SFMCSdk.setLogging(LogLevel.DEBUG, LogListener.AndroidLogger())
            MarketingCloudSdk.setLogLevel(MCLogListener.VERBOSE)
            MarketingCloudSdk.setLogListener(MCLogListener.AndroidLogListener())
          }

          try {
            val application = applicationContext as? Application
              ?: throw IllegalStateException("Application context is not an Application instance.")

            SFMCSdk.configure(application, SFMCSdkModuleConfig.build {
              pushModuleConfig = MarketingCloudConfig.builder().apply {
                setApplicationId(appId)
                setAccessToken(accessToken)
                setSenderId(senderId)
                setMarketingCloudServerUrl(appEndpoint)
                setNotificationCustomizationOptions(
                  NotificationCustomizationOptions.create(R.drawable.mce_push_notification)
                )
              }.build(applicationContext)
            }) { initStatus ->
              // ④
              val resultMap = mutableMapOf<String, String>()

              // ⑤
              resultMap["init"] = if (initStatus.status.toString() == "1") "success" else "error"

              // ⑥
              SFMCSdk.requestSdk { sdk ->
                try {
                  sdk.mp {
                    it.pushMessageManager.enablePush()
                  }
                  resultMap["enablePush"] = "success"
                } catch (e: Exception) {
                  Log.e("MethodChannel", "Error enabling push", e)
                  resultMap["enablePush"] = "error: ${e.message}"
                } finally {
                  result.success(resultMap)
                }
              }
            }
          } catch (e: Exception) {
            val resultMap = mutableMapOf<String, String>()
            resultMap["init"] = "error: ${e.message}"
            resultMap["enablePush"] = "not attempted due to init error"
            result.success(resultMap)
          }
        }
        METHOD_REGISTER_USER -> {
          val userid = methodCall.argument<String>("userId") ?: run {
            result.error("NULL_USER_ID", "userId is null", null)
            return@setMethodCallHandler
          }

          // ⑦
          SFMCSdk.requestSdk { sdk ->
            sdk.identity.setProfileId(userid)
          }

          // ⑧
          result.success("Registered userId: $userid")
        }
        else -> result.notImplemented()
      }
    }
  }
}

```

__①__ チャネル名やメソッド名を変数に格納しておきます。ここで指定した名前はDartで記載したものと一致している必要があります。
__②__ Dartと連携するためのMethodChannelのインスタンスを作成します。 このチャネルを経由してDartと連携する形となります。
__③__ SDK初期化用のメソッドがDartで実行された際の処理を書いていきます。Dartから渡されたパラメータを取得して、SDKの初期化を実行します。
__④__ Dart側に返す結果用のMapを作成しておきます。
__⑤__ 初期化の実行結果を格納します。
__⑥__ プッシュ通知の有効化処理を実行し、結果を④のMapに格納します。
__⑦__ ユーザーIDを登録するメソッドを実行します。
__⑧__ 結果をDartに返します。


ここまでの処理で一通りFlutterとAndroidのネイティブコードと連携ができました。
次に、iOS側の連携コードを書いていきます。

### ステップ3-3 (Swiftでの実装)


まずFlutterプロジェクトを作成すると、`ios/Runner/AppDelegate.swift`には下記のソースコードが記述されていると思います。

```swift
import UIKit
import Flutter

@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)
  }
}
```

AppDelegate.swiftファイルの中身を以下のように修正します。

```swift
import UIKit
import Flutter
import UserNotifications
import SFMCSDK
import MarketingCloudSDK

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, URLHandlingDelegate {
  // ①
  private let channelName = "com.example.mce_app/mce"
  private let methodInitMCESDK = "initMCESDK"
  private let methodRegisterUser = "registerUserId"
  
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
    // ②
    let channel = FlutterMethodChannel(name: channelName, binaryMessenger: controller.binaryMessenger)
    
    channel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
      switch call.method {
      case self?.methodInitMCESDK:
        // ③
        self?.handleInitMCESDK(call: call, result: result)
      case self?.methodRegisterUser:
        self?.handleRegisterUser(call: call, result: result)
      default:
        result(FlutterMethodNotImplemented)
      }
    }
    
    GeneratedPluginRegistrant.register(with: self)
    UNUserNotificationCenter.current().delegate = self
    UIApplication.shared.registerForRemoteNotifications()
    
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
  
  override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    SFMCSdk.mp.setDeviceToken(deviceToken)
  }
  
  override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print(error)
  }
  
  override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    SFMCSdk.mp.setNotificationUserInfo(userInfo)
    completionHandler(.newData)
  }
  
  @available(iOS 10.0, *)
  override func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
    SFMCSdk.mp.setNotificationRequest(response.notification.request)
    completionHandler()
  }
  
  @available(iOS 10.0, *)
  override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
    completionHandler(.alert)
  }
  
  private func handleInitMCESDK(call: FlutterMethodCall, result: @escaping FlutterResult) {
    guard let args = call.arguments as? [String: Any],
          let accessToken = args["accessToken"] as? String,
          let appId = args["appId"] as? String,
          let appEndpoint = args["appEndpoint"] as? String else {
      result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments received", details: nil))
      return
    }
    
    guard let appEndpointURL = URL(string: appEndpoint) else {
      result(FlutterError(code: "INVALID_URL", message: "Invalid appEndpoint URL", details: nil))
      return
    }

    let mobilePushConfiguration = PushConfigBuilder(appId: appId)
      .setAccessToken(accessToken)
      .setMarketingCloudServerUrl(appEndpointURL)
      .build()
    
    let completionHandler: (OperationResult) -> () = { initStatus in
      // ④
      var resultMap: [String: String] = [:]

      switch initStatus {
      case .success:
        self.setupMobilePush() // Upon success, call setupMobilePush()
        // ⑤
        resultMap["init"] = "Initialization successful"
        // ⑥
        resultMap["enablePush"] = "success"
      default:
        resultMap["init"] = "Error initializing SDK: \(initStatus)"
        resultMap["enablePush"] = "not attempted due to init error"
      }

      result(resultMap)
    }
    
    SFMCSdk.initializeSdk(ConfigBuilder().setPush(config: mobilePushConfiguration, onCompletion: completionHandler).build())
  }
  
  private func setupMobilePush() {
    SFMCSdk.mp.setURLHandlingDelegate(self)
    
    DispatchQueue.main.async {
      UNUserNotificationCenter.current().delegate = self
      UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge], completionHandler: {(_ granted: Bool, _ error: Error?) -> Void in
        if error == nil, granted == true {    
        }
      })
      UIApplication.shared.registerForRemoteNotifications()
    }
  }
  
  internal func sfmc_handleURL(_ url: URL, type: String) {
    print("Received URL: \(url) of type: \(type)")
  }
  
  private func handleRegisterUser(call: FlutterMethodCall, result: @escaping FlutterResult) {
    guard let args = call.arguments as? [String: Any],
          let userId = args["userId"] as? String else {
      result(FlutterError(code: "NULL_USER_ID", message: "userId is null", details: nil))
      return
    }

    // ⑦
    SFMCSdk.identity.setProfileId(userId)

    // ⑧
    result("Registered userId: \(userId)")
  }
}


```
①〜⑨はKotlinの項目と同じです
（再掲）
__①__ チャネル名やメソッド名を変数に格納しておきます。ここで指定した名前はDartで記載したものと一致している必要があります。
__②__ Dartと連携するためのFlutterMethodChannel(※iOSの場合はFlutterMethodChannel)のインスタンスを作成します。 このチャネルを経由してDartと連携する形となります。
__③__ SDK初期化用のメソッドがDartで実行された際の処理を書いていきます。Dartから渡されたパラメータを取得して、SDKの初期化を実行します。
__④__ Dart側に返す結果用のMapを作成しておきます。
__⑤__ 初期化の実行結果を格納します。
__⑥__ プッシュ通知の有効化処理を実行し、結果を④のMapに格納します。
__⑦__ ユーザーIDを登録するメソッドを実行します。
__⑧__ 結果をDartに返します。


これでAndroid, iOSともに連携する準備ができました。
それでは実際に起動して、コンタクトの作成とプッシュ通知が届くか試してみましょう。

### コンタクト生成結果

Android アノニマスユーザー生成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3564999/1b416717-87ba-5a1a-f870-69a92287787b.png)

Android Knownユーザー生成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3564999/3def866a-10db-d817-91a3-4ac8a6912951.png)

iOS アノニマスユーザー作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3564999/69ec21f2-1979-d111-cc57-3643f67ef137.png)

iOS Knownユーザー生成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3564999/10c71447-755a-91fc-5a32-9946c633848a.png)

### プッシュ通知結果
| Android | iOS |
|---------|---------|
| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3564999/48f6ad72-7a6b-da9d-bdd2-1d9d30889a9c.png) | ![Image from iOS.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3564999/ade72c7f-8e09-a871-e246-538501342846.jpeg) |

## まとめ
このようにMethodChannelを利用することでFlutterとプラットフォームの連携が可能となります。Flutterエンジニアとしては、Dartのみで完結したいところではありますが連携方法はシンプルなので理解しやすいかと思います。

この記事ではプッシュ通知が届くところまでのコードを紹介しましたが、あくまでも実装サンプルであり動作保証するものではない点をご注意ください。実際に組み込む場合は、入念なテストの実施を推奨します。

---
参考リンク
- https://docs.flutter.dev/platform-integration/platform-channels
- https://dev.to/abhinandrk/flutters-methodchannel-to-invoke-activity-in-android-and-view-controller-in-ios-3f7j
- https://dev.to/ramgendeploy/run-java-code-in-flutter-methodchannel-template-15o0?comments_sort=latest