Flutter и натив. Пример с Яндекс OAuth

Краткое содержание этого чуда

Разберем как Flutter взаимодействует c нативными хост приложениями и как можно использовать это в наших проектах. Для примера возьмем реализацию работы библиотеки Яндекс OAuth. Почему именно ее? Потому что существующее решение не захотело работать в моем проекте, и я написал свое с блэк-джеком и MethodChannel. Также доступен пример реализации в репозитории github

Немного теории для простых смертных

Документация по работе с нативом

Приложение Flutter встраивается (можно сказать воспроизводится) в нативное приложение. Для обращения к нативному приложению хосту есть класс MethodChannel

import 'package:flutter/services.dart';
static const platform = MethodChannel('example.kotelnikoff.expert/example');

Проще говоря вы можете создать канал для общения с нативной платформой, добавить слушатели событий на каждой платформе и обмениваться сообщениями. Для передачи вы можете использовать следующие типы:

Dart

Java

Kotlin

Swift

null

null

null

nil

bool

java.lang.Boolean

Boolean

NSNumber (value: Bool)

int

java.lang.Integer

Int

NSNumber (value: Int32)

int, if 32 bits not enough

java.lang.Long

Long

NSNumber (value: Int)

double

java.lang.Double

Double

NSNumber (value: Double)

String

java.lang.String

String

String

Uint8List

byte[]

ByteArray

FlutterStandardTypedData (bytes: Data)

Int32List

int[]

IntArray

FlutterStandardTypedData (int32: Data)

Int64List

long[]

LongArray

FlutterStandardTypedData (int64: Data)

Float32List

float[]

FloatArray

FlutterStandardTypedData (float32: Data)

Float64List

double[]

DoubleArray

FlutterStandardTypedData (float64: Data)

List

java.util.ArrayList

List

Array

Map

java.util.HashMap

HashMap

Dictionary

Примеры использования нативного кода

На стороне Flutter

Для общения с нативом вам требуется создать канал MethodChannel. И все! вы можете вызывать созданные вами методы и передавать аргументы на нативную платформу при помощи invokeMethod

 // Создаем канал. 'kotelnikoff_dev' - его название.
    // Документация рекомендует использовать имя пакета и функцию 
    // (например "samples.flutter.dev/battery"). Но это не обязательно. 
    // Важно использовать название канала символ в символ на нативной платформе
    final _methodChannel = const MethodChannel('kotelnikoff_dev');
    // Вызов метода на нативе. Метод принимает 2 аргумента String method,
    // [dynamic arguments].
    // method - должен совпадать символ в символ с нативом
    // arguments - необязательно. Можно передать простые типы. (список ниже)
    final message=await _methodChannel.invokeMethod('ping',
                                                    'Say hi to my little friends');

На стороне Android

Для работы с нативом я выбрал kotlin, потому что он выбран по-умолчанию. Для работы нужно лишь добавитьMethodChannel.MethodCallHandler и пару строк душистого кода в app/src/main/kotlin/имя/вашего/пакета/MainActivity.kt

class MainActivity: FlutterActivity(), MethodChannel.MethodCallHandler {
    // Имя канала символ в символ
    private final var CHANEL_NAME="kotelnikoff_dev"


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // При создании Activity создаем канал 
        // и добавляем слушатель вызовов из Flutter
        val channel = MethodChannel(
          flutterEngine!!.dartExecutor.binaryMessenger, CHANEL_NAME)
        channel.setMethodCallHandler(this)
    }

    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        // Наш метод из кода выше
        if (call.method == "ping") {
            //Смотрим на arguments и если они совпадают, то вернем успешное
            //выполнение запроса
            if(call.arguments.toString() == "Say hi to my little friends"){
                result.success("Hello")
            }else{
                // А тут вернем ошибку
                result.error("PingError", "What", "I don't know this phrase")
            }
        } else {
            // Ошибка на случай если метод не найден
            result.notImplemented()
        }
    }
}

На стороне IOS

Для работы на IOS нам так же потребуется создать канал и слушателя в файле ios/Runner/AppDelegate.swift

override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)
        
        guard let controller = window?.rootViewController as? FlutterViewController else {
            fatalError("Invalid root view controller")
        }
        //Создаем нанал
        let channel = FlutterMethodChannel(name: "kotelnikoff_dev", binaryMessenger: controller.binaryMessenger)
        // Слушаем сообщения
        channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
        
            if call.method == "ping" {
                //Смотрим на arguments и если они совпадают, то вернем успешное выполнение запроса
                if call.arguments == "Say hi to my little friends" {
                    result("Hello")
                }else {
                    // А тут вернем ошибку
                    result(FlutterError(code: "PingError",
                                        message: "What",
                                        details: "I don't know this phrase"))

                }
            }
            else {
                // Ошибка на случай если метод не найден
                result(FlutterMethodNotImplemented)
            }
        }
        
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

И на этом все. Наше приложение может общаться с хост платформами. Правда, сейчас от этого смысла как от строчки в резюме о знании нативных платформ. Но мы сейчас мы это исправим.

Пример приложения для работы Yandex ID

Как я ранее говорил на просторах интернета есть готовый пакет для Flutter и яндекс авторизации, но он давно не обновлялся и для создания велосипеда меня уговаривать не Придется, где тут либа какая-нибудь?

Начало работы

Все как всегда начинается с документации, анализа и подключения. Документация тут. Требования тут:

  • Для android: имя пакета и отпечаток приложения (результат метода getCertificateFingerprint)

  • Для IOS: имя пакета и Team ID из консоли разработчика.

Подготовка приложения для Android

Ссылка на документацию

  1. Добавляем в файл android/app/build.gradle ключ приложения из консоли и зависимость com.yandex.android:authsdk

android {
.....
	defaultConfig {
		....
		///Можно и повыше
		minSdkVersion 23
		//Добавим клюя авторизации для работы либы  
		manifestPlaceholders += [YANDEX_CLIENT_ID:"Ключ приложения"]
		...
	}
}
dependencies {  
    //Добавим библиотеку  
    implementation "com.yandex.android:authsdk:3.1.0"  
}
  1. Для работы com.yandex.android:authsdk требуется изменить версию kotlin в файле android/settings.gradle. Это нужно именно для authsdk. Если вы хотите использовать, что то другое, то можно не менять эту зависимость.

plugins {
	/// Обновить версию 1.8.0 или выше. Это нужно для библиотеки яндекса. Так можно не трогать
    id "org.jetbrains.kotlin.android" version "1.8.0" apply false 
}
  1. Готово

Подготовка на IOS

Ссылка на документацию

  1. Модифицировать ios/Podfile и добавить в него зависимость YandexLoginSDK

# Дефолтный файл Podfile
# Uncomment this line to define a global platform for your project  
platform :ios, '14.0'  
# Добавляем библиотеку авторизации яндекса  
pod 'YandexLoginSDK'  

# ....Продолжение файла...
  1. Добавить информацию в ios/Runner/Info.plist из документации

LSApplicationQueriesSchemes  
     
      primaryyandexloginsdk  
      secondaryyandexloginsdk  
     
   CFBundleURLTypes  
     
        
         CFBundleURLName  
         YandexLoginSDK  
         CFBundleURLSchemes  
           
            yx{ВАШ_КЛЮЧ}  
           
        
   

Блок CFBundleURLTypes должен быть только один на весь файл. Для нескольких сервисов просто создайте новые внутри

  1. Добавить домен applinks:yx{Client_ID}.oauth.yandex.ru в список ассоциированных доменов Capability: Associated Domains

  2. Готово!

Переходим к разработке

Вам доступен исходный код в репозитории github в нем есть комментарии во всех важных местах. И в не важных тоже. Тут только самые интересные моменты. В репозитории все методы (все 3) находятся в классе OAuthYandex. Для общения используются bool и JSON string. Почему не использую тип Map, если он доступен во всех языках? Потому что я художник и я так вижу.

Мини лайфхак.

Если вам нужно конвертировать JSON строку в объект, то можно закинуть ее quicktype.io и в ответе получить готовый файлик. Без регистрации и смс.

Метод получения отпечатка

Метод только для android. Нужен для получения SHA или SHA256 Fingerprint. Эти отпечатки необходимы для создания приложения в консоли разработчика яндекса. Да и в других местах они потребуются (VK. google и прочие штуки на андроиде). Главное не забыть добавить в консоль отпечаток релизной версии и версии из маркета.

Как это работает на стороне Flutter. Подробнее можно посмотреть репозитории,

Future getCertificateFingerprint({FingerprintType type=FingerprintType.SHA1})async{
    if(!Platform.isAndroid){
      throw UnsupportedError('Метод доступен только на android');
    }
    try{
      final message=await _methodChannel.invokeMethod('getCertificateFingerprint',type.label);
      //парсим ответ
      return fingerprintModelFromJson(message);
    }catch(e,s){
      rethrow;
    }
  }

Код на kotlin находится внутри onMethodCall

// этот метод нужен для получения отпечатка. можно использовать консольную команду,
// но она не всегда работает.
        else if(call.method=="getCertificateFingerprint"){
            try {
                val info = context.packageManager.getPackageInfo(
                    context.packageName,
                    PackageManager.GET_SIGNATURES
                )
                for (signature in info.signatures) {
                    val md: MessageDigest = MessageDigest.getInstance(call.arguments.toString())
                    md.update(signature.toByteArray())
                    val digest = md.digest()
                    val hexString = digest.joinToString(":") { "%02x".format(it) }
                    val res=mapOf(
                        "fingerprint" to hexString,
                        "packageName" to context.packageName
                    )
                    result.success(JSONObject(res.toMap()).toString())
                }
            } catch (e: Exception) {
                result.error("Error cert","${e.message}","${e.stackTrace}")
            }
        }

Запуск библиотеки

Для запуска Яндекс OAuth я создал метод start, который нужен для запуска библиотеки или получения ошибки с объяснением о том, почему ей не нравится наше великолепное приложение

На стороне flutter вызов не сильно отличается (поменяй getCertificateFingerprint на start). Поэтому тут будет только код на kotlin. В нем есть пример работы с ошибками. Сам код взят из документации.

if(call.method=="start"){
            try {
                yandexSdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext))
                result.success(true)
            }catch (e: Exception){
                result.error("InitError","${e.message}","${e.stackTrace}")
            }
        }

Запрос доступа

Теперь рассмотрим как получить информацию о пользователе. Для этого нам нужно создать Intent на приложения яндекса (Если его нет, то должен открыться браузер). Ответ мы получим в методе onActivityResult.

try{
                val loginOptions = YandexAuthLoginOptions()
                val intent: Intent = yandexSdk.contract.createIntent(
                  applicationContext,loginOptions)
                // Запускаем активити авторизации и ждем результат
                startActivityForResult(intent, REQUEST_LOGIN_YANDEX);
            }catch (e: Exception){
                result.error("InitError","${e.message}","${e.stackTrace}")
            }

/// много строк спустя 

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode==REQUEST_LOGIN_YANDEX){
            val sdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext))
            // метод из документации. Подробнее в репозитории
            handleResult(sdk.contract.parseResult(resultCode,data))
        }
        super.onActivityResult(requestCode, resultCode, data)
    }

На android это все. Полный код класса доступен в спойлере и в репозитории.

Полный код для android

package expert.kotelnikoff.oauthdemo

import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import com.yandex.authsdk.YandexAuthException
import com.yandex.authsdk.YandexAuthLoginOptions
import com.yandex.authsdk.YandexAuthOptions
import com.yandex.authsdk.YandexAuthResult
import com.yandex.authsdk.YandexAuthSdk
import com.yandex.authsdk.YandexAuthToken
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import java.security.MessageDigest

class MainActivity: FlutterActivity(), MethodChannel.MethodCallHandler {
    /// Это наш канал для работы с флаттером. Важно указать его символ в символ
    private final var CHANEL_NAME="kotelnikoff_dev"
    private lateinit var yandexSdk: YandexAuthSdk
    private var result: MethodChannel.Result? = null
    private val REQUEST_LOGIN_YANDEX = 100
    private lateinit var channel : MethodChannel
    private lateinit var context: Context;


    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        if(call.method=="start"){
            try {
                yandexSdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext))
                result.success(true)
            }catch (e: Exception){
                result.error("InitError","${e.message}","${e.stackTrace}")
            }
        }else if(call.method=="yandexAuth"){
            // создаем интент для запуска активити яндекса и авторизации в ней. Сохраняем result для последующего использования.
            if(this.result!=null){
                result.error("InitError","Прошлый запрос еще не выполнен, ждите","Подожди")
                return;
            }
            this.result=result
            // из документации яндекса
            try{
                val loginOptions = YandexAuthLoginOptions()
                val intent: Intent = yandexSdk.contract.createIntent(applicationContext,loginOptions)
                startActivityForResult(intent, REQUEST_LOGIN_YANDEX);
            }catch (e: Exception){
                result.error("InitError","${e.message}","${e.stackTrace}")
            }
        }
        // этот метод нужнен для получения отпечатка. можно использовать консольную команду, но у меня она не всегда работает.
        else if(call.method=="getCertificateFingerprint"){
            try {
                val info = context.packageManager.getPackageInfo(
                    context.packageName,
                    PackageManager.GET_SIGNATURES
                )
                for (signature in info.signatures) {
                    val md: MessageDigest = MessageDigest.getInstance(call.arguments.toString())
                    md.update(signature.toByteArray())
                    val digest = md.digest()
                    val hexString = digest.joinToString(":") { "%02x".format(it) }
                    val res=mapOf(
                        "fingerprint" to hexString,
                        "packageName" to context.packageName
                    )
                    result.success(JSONObject(res.toMap()).toString())
                }
            } catch (e: Exception) {
                result.error("Error cert","${e.message}","${e.stackTrace}")
            }
        }    else {
            result.notImplemented()
        }

    }
    // При создании нативной активити добавляем слушатель событий Flutter
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        context = applicationContext
        channel = MethodChannel(flutterEngine!!.dartExecutor.binaryMessenger, CHANEL_NAME)
        channel.setMethodCallHandler(this)
    }
    // метод из документации яндекса. смотрим на сколько успешным был запрос
    private fun handleResult(result: YandexAuthResult) {
        when (result) {
            is YandexAuthResult.Success -> onSuccessAuth(result.token)
            is YandexAuthResult.Failure -> onProccessError(result.exception)
            YandexAuthResult.Cancelled -> onCancelled()
        }
    }
    private fun onSuccessAuth(token: YandexAuthToken){
        val res=mapOf(
            "susses" to true,
            "token" to token.value,
            "expiresIn" to token.expiresIn,
        )

        if(result!==null){
            result!!.success(JSONObject(res.toMap()).toString())
            result=null
        }

    }
    /// слушаем результат выполнения запроса на авторизацию
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode==REQUEST_LOGIN_YANDEX){
            val sdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext))
            handleResult(sdk.contract.parseResult(resultCode,data))
        }
        super.onActivityResult(requestCode, resultCode, data)
    }
    private fun onProccessError(exception: YandexAuthException){
        val arg = HashMap()
        arg.put("susses",false)
        arg.put("provider","yandex")
        arg.put("error",exception.toString())
        if(result!==null){
            result!!.success(JSONObject(arg.toMap()).toString())
            result=null
        }

    }
    private fun onCancelled(){
        val arg = HashMap()
        arg.put("susses",false)
        arg.put("provider","yandex")
        arg.put("cancelled",true)
        if(result!==null){
            result!!.success(JSONObject(arg.toMap()).toString())
            result=null
        }
    }


}

Работаем с IOS

Нам необходимо реализовать методы start и yandexAuth на нативе IOS. Логика кода будет точно такой-же как и на android. Но сначала лайфхак

Если при запуске проекта вы увидите напротив import Flutter надпись «No such module 'Flutter' » , то вам нужно перейти в Runner и включить поддержку плагинов (скрины ниже)

Вот так выглядит ошибка

Вот так выглядит ошибка

Решение проблемы. Возможно прийдется перебилдить приложение в xcode (очистить билд и сбилдить)

Решение проблемы. Возможно прийдется перебилдить приложение в xcode (очистить билд и сбилдить)

Для работы с IOS яндекс подготовил YandexLoginSDKObserver. Создаем класс с NSObject и YandexLoginSDKObserver и добавляем необходимы для работы метод didFinishLogin

class MyYandexLoginSDKObserver: NSObject, YandexLoginSDKObserver {
  // тут отслеживаем результат авторизации
func didFinishLogin(with result: Result) {
        do {
            let res = try result.get()
            let response = SussesResponse(susses: true, token: res.token, expiresIn: -1)
            let encoder = JSONEncoder()
            let responseJson = try encoder.encode(response)
            let responseJsonString = String(data: responseJson, encoding: .utf8)
            // Если все хорошо, то отправляем строку в формате json во flutter приложение.
            self.resultFlutter(responseJsonString)
            
        }catch{
            let response = ErrorResponse(susses: false, errorMessage: "\(error)")
            let encoder = JSONEncoder()
            
            do {
                let responseJson = try encoder.encode(response)
                let responseJsonString = String(data: responseJson, encoding: .utf8)
                // Если все плохо, то передаем сведения об ошибке 
                self.resultFlutter(FlutterError.init(code: "errorSetDebug",message: responseJsonString,details:nil))
            }catch{
                
            }
        }
        
    }
}

Теперь мы можем работать с нативом IOS. Подключаемся к каналу и создаем код для методов start и yandexAuth

override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
      guard let controller = window?.rootViewController as? FlutterViewController else {
                  fatalError("Invalid root view controller")
              }
      //Подключаемся к нашему каналу
      let channel = FlutterMethodChannel(name: "kotelnikoff_dev", binaryMessenger: controller.binaryMessenger)
      // добавляем слушаем
      channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
                  if call.method == "start" {
                    
                      do {
                          let clientID = "{ВАШ_КЛЮЧ}" //<= ваш id приложения из консоли. В теории можно взять из Info.plist
                          try YandexLoginSDK.shared.activate(with: clientID)
                          self.myYandex = MyYandexLoginSDKObserver()
                          YandexLoginSDK.shared.add(observer: self.myYandex)
                          result(true)
                      } catch {
                          result(FlutterError(code: "InitError",
                                                                                message: "Error YandexLoginSDK \(error)",
                                                                                details: "\(error)"))

                          
                      }
                      
                  } else if call.method == "yandexAuth" {
                      if let viewController = UIApplication.shared.keyWindow?.rootViewController {
                          do{
                              self.myYandex.setResult(result: result);
                              try YandexLoginSDK.shared.authorize(with: viewController)
                          }catch{
                              result(FlutterError(code: "ERROR YandexLoginSDK",
                                                      message: "Error YandexLoginSDK \(error)",
                                                      details: "\(error)"))
                          }
                      }
                      
                  }
                  else {
                      result(FlutterMethodNotImplemented)
                  }
              }
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

Готово, теперь наше приложение работает и на IOS.

Полный код для IOS

import UIKit
import Flutter
import YandexLoginSDK


@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    var myYandex:MyYandexLoginSDKObserver=MyYandexLoginSDKObserver()

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
      guard let controller = window?.rootViewController as? FlutterViewController else {
                  fatalError("Invalid root view controller")
              }
      //Подключаемся к нашему каналу
      let channel = FlutterMethodChannel(name: "kotelnikoff_dev", binaryMessenger: controller.binaryMessenger)
      // добавляем слушаем
      channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
                  if call.method == "start" {
                    
                      do {
                          let clientID = "{ВАШ_КЛЮЧ}" //<= ваш id приложения из консоли. В теории можно взять из Info.plist
                          try YandexLoginSDK.shared.activate(with: clientID)
                          self.myYandex = MyYandexLoginSDKObserver()
                          YandexLoginSDK.shared.add(observer: self.myYandex)
                          result(true)
                      } catch {
                          result(FlutterError(code: "InitError",
                                                                                message: "Error YandexLoginSDK \(error)",
                                                                                details: "\(error)"))

                          
                      }
                      
                  } else if call.method == "yandexAuth" {
                      if let viewController = UIApplication.shared.keyWindow?.rootViewController {
                          do{
                              self.myYandex.setResult(result: result);
                              try YandexLoginSDK.shared.authorize(with: viewController)
                          }catch{
                              result(FlutterError(code: "ERROR YandexLoginSDK",
                                                      message: "Error YandexLoginSDK \(error)",
                                                      details: "\(error)"))
                          }
                      }
                      
                  }
                  else {
                      result(FlutterMethodNotImplemented)
                  }
              }
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

class MyYandexLoginSDKObserver: NSObject, YandexLoginSDKObserver {
    var resultFlutter: FlutterResult!
    
    
    func setResult(result: @escaping FlutterResult){
        self.resultFlutter = result
    }
    
    func didFinishLogin(with result: Result) {
        do {
            let res = try result.get()
            
            
            
            
            let response = SussesResponse(susses: true, token: res.token, expiresIn: -1)
            
            
            let encoder = JSONEncoder()
            
            let responseJson = try encoder.encode(response)
            let responseJsonString = String(data: responseJson, encoding: .utf8)
            
            self.resultFlutter(responseJsonString)
            
        }catch{
            let response = ErrorResponse(susses: false, errorMessage: "\(error)")
            let encoder = JSONEncoder()
            
            do {
                let responseJson = try encoder.encode(response)
                let responseJsonString = String(data: responseJson, encoding: .utf8)
                
                self.resultFlutter(FlutterError.init(code: "errorSetDebug",message: responseJsonString,details:nil))
            }catch{
                
            }
        }
        
    }
}


//Это нужно для типизации успешного результата с токеном. Я не нашел как это реализовать иначе. Если есть идеи, то напишите в комменты
struct SussesResponse: Encodable {
    var susses: Bool
    var token: String
    var expiresIn: Int
}

struct ErrorResponse: Encodable {
    var susses: Bool
    var errorMessage: String
}

Готово

Наше приложение может использовать авторизацию yandex на уровне нативных хост приложений.

Осталось только обменять полученный токен на данные пользователя. Для этого необходимо выполнить запрос (лучше на сервере)

curl --location 'https://login.yandex.ru/info' \
--header 'Authorization: OAuth {Полученный_токен_в_приожении}'

И все готово.

P.S. Если есть идеи как можно украсить код на нативныйх платформах, то жду предложений в комментариях. Особенно для ios.

Посетить телеграмм канал автора можно посетить тут

Подкинте программисту на кофе, ему еще песика кормить,

Habrahabr.ru прочитано 3586 раз