Open12

【Flutter x Square】Square APIで学ぶMethodChannel

heyhey1028heyhey1028

Square Payment関連のAPI・SDK

SquareのAPI・SDKの構成

  • In-App Payment
  • In-Person
    • Terminal API
    • Reader SDK
    • Point of Sales API

In-App paymentIn-Person paymentの違い

◆ In-App payment

  • 購入者向けサービス
  • アプリ内でクレカを登録し、Readerでの操作無く支払いが可能
  • 支払い情報に応じてpayment tokenが発行され、そのtokenをバックエンドに送り、Payment APIを使ってキャプチャされる事で支払いが完了する
  • クライアントサイドでIn-App payment SDKでtoken発行 ▶︎ バックエンドでPayment APIを使い、payment tokenをキャプチャ
  • squareが用意したUI componentでクレカ登録を行う。UIの多少のカスタマイズは可能

https://www.youtube.com/watch?v=oZlmWxyN5_M&list=PLKxvFH5604ZGiHyGNzEo6V8zfZWMhryhA

◆ In-Person payment

  • Terminal APIReader SDKPoint of Sales APIで構成される
  • Terminal API: どんなサードパーティPOSアプリからでもSquare ターミナルを使った決済を実現する。クラウドベース。Sandboxが使える。
  • Reader SDK: square check outフローをアプリに導入する事が出来る。ReaderとStandに対応。Sandboxが使えない。アメリカでのみ使用可能。
  • Point of Sales API: Squareのアプリをシームレスに起動し、Readerでの決済を可能にする。Sandboxは使えない。
heyhey1028heyhey1028

Reader SDKの使用上の制約

https://developer.squareup.com/docs/reader-sdk/what-it-does
色々制約がありそう。気になるのは下記

▶︎ Reader SDKの使用可能エリア★

The Reader SDK is only available for accounts based in the United States. Authorization requests for accounts based outside the United States return an error.

▶︎ Payments APIが使えない?

The Reader SDK does not support the Payments API. Use the Transactions API to get payments generated by the Reader SDK.

▶︎ 決済最低金額は$1

Card payments with the Reader SDK must be $1 or greater. The minimum card payment accepted by the SDK is $1 (100). An attempt to charge a lower amount returns the checkout_amount_below_card_payment_minimum error. The amount limit does not apply to other tender types.

▶︎ 払い戻しは行えない (Refund API使ってってだけ)

The Reader SDK cannot issue refunds. Refunds can be issued programmatically using the Refunds API or manually in the Square Seller Dashboard.

heyhey1028heyhey1028

基礎

▶︎ Dart側

  • MethodChannel('<メソッド名>')でChannel名を文字列で定義(メソッドとは別)
  • Channel名は慣習的に'<bundle name>/<チャネル名>' で定義
  • MethodChannelクラスに定義したinvokeMethodメソッドで発火したいメソッドの名前を渡す
const final sampleChannel = MethodChannel('com.example.toybox')

sampleChannel.invokeMethod('getBattery')

▶︎ iOS側

  • FlutterMethodChannelクラスを通して、どのメソッドを実行したいかを受け取る必要がある
  • そのFlutterMethodChannelクラスを使うには、FlutterViewControllerクラスを定義する必要がある
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
  • 更にChannel内にセットされた複数のメソッドのハンドラーをchannelクラスのsetMethodCallHandlerで取得できる
  • このメソッドがいわばトンネルの様な役割となり、Flutter側で呼び出されたメソッドが流れてくる→handlerではこのcallがFlutterMethodCallクラスとして流れてくる
  • call.methodにはFlutter側で定義したメソッド名が入っているので、そのメソッド名を使って分岐させる
  • call.argumentsにはFlutter側で渡した引数が格納されており、Platform側のメソッドに引数を渡す事も出来る
  • flutter側ではMapとして定義する
final arguments = {'name': 'John'}
  • platform側でas句でキャストする
guard let args = call.arguments as? [String: String] else {return}
let name = args["name"]
  • swiftでは同クラス内に定義されたprivateメソッドの呼び出しはself句を使う ex. self.receiveBatteryLevel()

参考

https://www.youtube.com/watch?v=EHQTdB2qenU

heyhey1028heyhey1028

エラーハンドリング

  • Dart→Native, Native→Dartの処理が非同期処理として両方に用意されている
  • その非同期処理間をバイナリデータのやり取りをしてる
  • このバイナリデータのエンコード・デコードはDart層、Android層、iOS層それぞれに用意されたMethodCodecクラスにより行われている
  • MethodCodecクラスは抽象クラスなので実際にはそれを継承したStandardMethodCodecクラス(Android)やFlutterStandardCodecクラス(iOS)により処理される
  • このMethodCodecクラスでは処理の成功をencodeSuccessEnvelopeメソッド、失敗をencodeErrorEnvelopeメソッドとして実装している
  • Platform側でエラーが起こるとDart側ではPlatformExceptionがthrowされる
  • PlatformExceptionクラス
・code → String
  ・An error code.
・details → dynamic
  ・Error details, possibly null.
・message → String?
  ・A human-readable error message, possibly null.
・stacktrace → String?
  ・Native stacktrace for the error, possibly null.
  • iOSのFlutterMethodCodecクラスのencodeErrorEnvelopeメソッドにはstackTraceを返却する仕組みが存在しない
  • resultに FlutterMethodNotImplemented がセットされていたら nilを返す
    • Dart層で MissingPluginExceptionが発生
  • resultに FlutterErrorクラスのインスタンスがセットされていたらエラー
    • Dart層で PluginExceptionが発生
  • それ以外の場合は成功
  • FlutterErrorをresultに明示的に渡した場合は、Dart側でPluginExceptionが発生
  • エラーハンドリングのすっぽ抜けがあった場合はクラッシュする可能性が高い

参考

https://qiita.com/kunichiko/items/fe492a201c4e28a49536#iosの結果返却の実装

https://csdcorp.com/blog/coding/error-handling-in-flutter-plugins/

heyhey1028heyhey1028

Dart側

① MethodChannelのインスタンス化

  static const squareChannel = MethodChannel('kiosk.flutter/square');

② MethodChannelのメソッドに渡す引数の生成

  • Mapで生成する
    final arguments = <String, dynamic>{
      'price': 140,
      'memo': 'test',
      'disablesKeyedInCardEntry': true,
    };

③ MethodChannelクラスを使ってメソッドの呼び出し

  • 第一引数に呼び出すメソッド名をStringで指定
  • 第二引数にMapの引数を指定
  • 呼び出すメソッド名はこの後のPlatform側に定義されているメソッドである事
    final transactionID =
        await squareChannel.invokeMethod<String?>('openSquare', arguments);

全体

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../widgets/app_scaffold.dart';

final transactionProvider =
    StateProvider.autoDispose<String>((ref) => 'No Transaction Yet');

class SampleScreen extends ConsumerWidget {
  const SampleScreen({super.key});

  static const squareChannel = MethodChannel('kiosk.flutter/square');
  
  Widget build(BuildContext context, WidgetRef ref) {
    return AppScaffold(
      color: Colors.grey,
      body: Stack(
        children: [
          Center(
            child: Text(
              ref.watch(transactionProvider),
              style: const TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          Align(
            alignment: const Alignment(0, 0.7),
            child: ElevatedButton(
              style: ElevatedButton.styleFrom(
                shape: const StadiumBorder(),
              ),
              onPressed: () => _openSquareReaderPayment(ref),
              child: const Text('OPEN SQUARE'),
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _openSquareReaderPayment(
    WidgetRef ref,
  ) async {
    final arguments = <String, dynamic>{
      'price': 140,
      'memo': 'test',
      'disablesKeyedInCardEntry': true,
    };
    final transactionID =
        await squareChannel.invokeMethod<String?>('openSquare', arguments);

    ref
        .read(transactionProvider.notifier)
        .update((state) => 'Success with $transactionID');
  }
}

heyhey1028heyhey1028

iOS側

https://developer.squareup.com/docs/pos-api/build-on-ios

MethodChannel一般

FlutterViewControllerをインスタンス化

  • iOS側のMethodChannelを生成に必要なFlutterViewControllerをインスタンス化する。
  • Dart側から渡ってくるバイナリデータを変換してくれる?

FlutterMethodChannelをインスタンス化

  • Dart側でも定義したMethodChannel名と先程インスタンス化したFlutterViewControllerを使って、FlutterMethodChannelをインスタンス化

③チャネルのMethodCallHandlerを呼び出す

  • 指定したチャネルで渡されるメソッドをハンドリングするsetMethodCallHandlerメソッドを呼び出す
  • コールバックで渡されるFlutterMethodCallにDart側から呼び出されているメソッド名が渡ってくる
  • そのメソッド名に応じて条件分岐でどのような処理をplatform側で行うかを記述
  • Dart側から渡ってきた引数はcall.argumentsで受け取れる
  • Dart側に返したい返り値はFlutterResultクラスに格納してreturnする

全体

AppDelegate.swift
import UIKit
import Flutter
import SquarePointOfSaleSDK

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {

    private var final_result: FlutterResult?

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
      let controller : FlutterViewController = window?.rootViewController as! FlutterViewController

      let squareChannel = FlutterMethodChannel(name:"kiosk.flutter/square", binaryMessenger: controller.binaryMessenger)

      squareChannel.setMethodCallHandler({
        (call: FlutterMethodCall, result: @escaping FlutterResult)-> Void in
        guard call.method == "openSquare" else {
          result(FlutterMethodNotImplemented)
          return
        }
          
          let parameters = call.arguments as! Dictionary<String, Any>
          if let price = parameters["price"] as? Int,
             let memo=parameters["memo"] as? String,
             let disablesKeyedInCardEntry = parameters["disablesKeyedInCardEntry"] as? Bool{
              self.openSquare(result: result, price:price, memo:memo, disablesKeyedInCardEntry: disablesKeyedInCardEntry)
          }else{
              print("パラメータの型が不正です")
              result(FlutterMethodNotImplemented)
              return
          }
      })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
heyhey1028heyhey1028

Square POS API特有

① Square POSアプリに対するリクエストを生成

  1. callbackURLの設定
    「Square Developer Portal > Application > Point of Sale API > iOS app URL schemes」にて設定したURLをcallbackURLとしてセットする
openSquarePOSメソッド
let callbackURL = URL(string: "xxxxx://")!
  1. 支払い金額の設定
openSquarePOSメソッド
        var amount: SCCMoney?
        do{
            // 支払い金額の設定
            amount = try SCCMoney(amountCents: price, currencyCode: "JPY")
        }catch {
            // エラーハンドリング
        }
  1. アプリケーションIDの設定
    「Square Developer Portal > Application > Credentials > Production Application ID」をSCCAPIRequestクラスにセットする
openSquarePOSメソッド
    SCCAPIRequest.setApplicationID("xxxxxxxxxxxxxxx")
  1. リクエストパラメータの作成
openSquarePOSメソッド
        var request: SCCAPIRequest?
        do{
            request = try SCCAPIRequest(callbackURL: callbackURL,
                                        amount:amount!,
                                        userInfoString: memo, // 注文番号
                                        locationID: nil,
                                        notes:memo,
                                        customerID: nil,
                                        supportedTenderTypes: .all, // 支払い方法の設定
                                        clearsDefaultFees: true, // ???
                                        returnsAutomaticallyAfterPayment: true,
                                        disablesKeyedInCardEntry: disablesKeyedInCardEntry,
                                        skipsReceipt: true
            )
        }catch{
            // エラーハンドリング
        }
  1. Square POSアプリに対するリクエスト
    SCCAPIConnectionクラスのperformメソッドでSquare POSアプリを立ち上げ、リクエストを投げる
openSquarePOSメソッド
        var success = false
        do{
            try SCCAPIConnection.perform(request!)
            success = true
        }catch let error as NSError{
            print(error.localizedDescription)
        }

② Square POSアプリからの返答を受け取るメソッドを実装

application(_:open:options:)メソッドを使って、指定したURLのリソース(この場合Square POSアプリ)を立ち上げ、

③ レスポンスのエラーハンドリング

  • response.errorはそのままではErrorクラスとして返却されるのでNSErrorにキャストする事で、エラーコード、エラードメインを取得出来る様になる
AppDelegate.swift
            if let error = response.error as NSError? {
                // Handle a failed request.
                print("Error: \(error.localizedDescription)")
            }

全体

AppDelegate.swift
    private func openSquare(result:@escaping FlutterResult, price: Int, memo: String, disablesKeyedInCardEntry: Bool){
        final_result = result
        
        let callbackURL = URL(string: "square-sample://")!
        
        var amount: SCCMoney?
        do{
            // 支払い金額の設定
            amount = try SCCMoney(amountCents: price, currencyCode: "JPY")
        }catch {
            // エラーハンドリング
        }
        
        SCCAPIRequest.setApplicationID("xxxxxxxxxxxxxxxxxxx")

        var request: SCCAPIRequest?
        do{
            request = try SCCAPIRequest(callbackURL: callbackURL,
                                        amount:amount!,
                                        userInfoString: memo, // 注文番号
                                        locationID: nil,
                                        notes:memo,
                                        customerID: nil,
                                        supportedTenderTypes: .all, // 支払い方法の設定
                                        clearsDefaultFees: true, // ???
                                        returnsAutomaticallyAfterPayment: true,
                                        disablesKeyedInCardEntry: disablesKeyedInCardEntry,
                                        skipsReceipt: true
            )
        }catch{
            // エラーハンドリング
        }
        
        // var success = false
        do{
            try SCCAPIConnection.perform(request!)
            // success = true
        }catch let error as NSError{
            print(error.localizedDescription)
        }
        
        
    }
    
    override func application(_ app:UIApplication, open url: URL, options:[UIApplication.OpenURLOptionsKey: Any]=[:])-> Bool{
        
        // guard SCCAPIResponse.isSquareResponse(url)else{return false}
        
        // var decodeError: Error?
        
        do{
            let response = try SCCAPIResponse(responseURL: url)
            
            if let error = response.error{
                // Handle a failed request.
                print("Error: \(error.localizedDescription)")
            }
            // Print checkout object
            print("Transaction successful: \(response)")
            final_result?(response.transactionID)
            return true
        }catch let error as NSError{
            // Handle unexpected errors.
            print(error.localizedDescription)
        }

        return false
    }
}
heyhey1028heyhey1028

Squareの事前準備

  1. Square Developer PortalよりApplicationを作成
  2. 「Application > Point of Sale API > iOS app bundle IDs」にbundle IDを追加
  3. Square Point of Sale SDKのインストール
  4. URLスキームの追加