Appearance
Hướng dẫn tích hợp Flutter
Hướng dẫn tích hợp VBot SDK vào ứng dụng Flutter thông qua giao thức Native Bridge (MethodChannel và EventChannel) sử dụng phiên bản SDK mới nhất cho cả Android và iOS.
Kiến trúc tích hợp
Ứng dụng Flutter sẽ giao tiếp với Native SDK thông qua:
- MethodChannel: Để gọi các hàm xử lý từ Flutter xuống Native (khởi tạo, kết nối, gọi điện, gác máy, bật loa...).
- EventChannel: Để truyền ngược các sự kiện thay đổi trạng thái cuộc gọi từ Native lên Flutter dưới dạng Stream.
1. Cấu hình phía Android
Cấu hình Repositories và Dependencies
- Thêm JitPack repository vào tệp
android/settings.gradle(hoặcbuild.gradleở thư mục gốc của dự án):
groovy
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}- Mở tệp
android/app/build.gradlevà thêm các thư viện phụ thuộc bắt buộc cùng với VBot SDK Android phiên bản mới nhất:
groovy
dependencies {
// Các thư viện phụ thuộc bắt buộc để SDK hoạt động ổn định
implementation 'io.reactivex.rxjava2:rxjava:2.2.21'
implementation 'com.google.code.gson:gson:2.11.0'
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.11.0'
implementation 'org.reactivestreams:reactive-streams:1.0.4'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.14'
implementation 'com.jakewharton.timber:timber:5.0.1'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.0'
// VBot Phone SDK Android Public
implementation 'com.github.VBotDevTeam:VBotPhoneSDKAndroid-Public:1.0.12'
}Cấu hình Native Code (Kotlin)
Mở tệp MainActivity.kt của dự án và cài đặt MethodChannel và EventChannel:
kotlin
package com.vpmedia.vbotsdksample
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import com.vpmedia.sdkvbot.client.VBotClient
import com.vpmedia.sdkvbot.client.VBotConfig
import com.vpmedia.sdkvbot.client.ClientListener
import com.vpmedia.sdkvbot.en.AccountRegistrationState
import com.vpmedia.sdkvbot.en.CallState
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private var tokenFirebase: String = ""
private var resultWrapper: ResultWrapper? = null
companion object {
@SuppressLint("StaticFieldLeak")
lateinit var client: VBotClient
var events: EventChannel.EventSink? = null
var isIncoming = false
fun initClient(context: Context) {
if (::client.isInitialized) return
client = VBotClient(context)
// Khởi tạo với cấu hình mặc định (hoặc truyền cấu hình môi trường)
client.setup(VBotConfig())
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
initClient(context)
client.addListener(listener)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.vpmedia.vbot-sdk/vbot_phone")
.setMethodCallHandler(this)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.vpmedia.vbot-sdk/call")
.setStreamHandler(this)
}
private var listener = object : ClientListener() {
override fun onAccountRegistrationState(status: AccountRegistrationState, reason: String) {
if (status == AccountRegistrationState.Ok) {
resultWrapper?.success(mapOf("displayName" to client.getAccountUsername()))
} else if (status == AccountRegistrationState.Error) {
resultWrapper?.error("ERROR", reason, null)
}
}
override fun onCallState(state: CallState) {
runOnUiThread {
val stateStr = when (state) {
CallState.Null -> "none"
CallState.Calling, CallState.Early -> "calling"
CallState.Incoming -> "incoming"
CallState.Connecting -> "connecting"
CallState.Confirmed -> "confirmed"
else -> "disconnected"
}
events?.success(mapOf(
"state" to stateStr,
"isIncoming" to isIncoming
))
}
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
resultWrapper = ResultWrapper(result)
when (call.method) {
"connect" -> {
val token = call.argument<String>("token") ?: ""
client.connect(token, tokenFirebase)
}
"disconnect" -> {
val status = client.disconnect()
result.success(mapOf("disconnect" to status))
}
"startCall" -> {
val phoneNumber = call.argument<String>("phoneNumber") ?: ""
val hotline = call.argument<String>("hotline") ?: ""
isIncoming = false
client.startCall(hotline, phoneNumber)
result.success(mapOf("phoneNumber" to phoneNumber))
}
"answer" -> client.answerCall()
"hangup" -> client.endCall()
"mute" -> client.muteCall(true)
"speaker" -> client.onOffSpeaker(true)
else -> result.notImplemented()
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
MainActivity.events = events
}
override fun onCancel(arguments: Any?) {
events = null
}
}2. Cấu hình phía iOS
Cấu hình Podfile
WARNING
VBot iOS SDK chỉ hoạt động trên thiết bị thật, không hỗ trợ trình giả lập (Simulator).
Mở tệp ios/Podfile và thêm cấu hình:
ruby
platform :ios, '13.5'
source "https://github.com/CocoaPods/Specs.git"
target 'Runner' do
use_frameworks! :linkage => :static
# VBot Phone SDK iOS Public
pod 'VBotPhoneSDKiOS-Public', '1.1.7'
end
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
# Cấu hình bắt buộc để biên dịch SDK
config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'YES'
end
end
endChạy lệnh cài đặt pod:
bash
cd ios && pod installCấu hình Background Modes và Microphone
Bật tính năng trong Xcode Project:
- Vào Xcode Project → Target Runner → Signing & Capabilities
- Bật Background Modes và thêm các quyền:
- Audio, AirPlay, and Picture in Picture
- Voice over IP (VoIP)
- Background Fetch
- Remote notifications
Mở tệp
ios/Runner/Info.plistvà thêm khóa xin quyền Microphone:
xml
<key>NSMicrophoneUsageDescription</key>
<string>Quyền truy cập Microphone để thực hiện cuộc gọi VoIP.</string>Cấu hình Native Code (Swift)
Mở tệp ios/Runner/AppDelegate.swift và thiết lập kết nối:
swift
import Flutter
import UIKit
import VBotPhonePublic
import PushKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterStreamHandler, VBotPhoneDelegate {
private var eventSink: FlutterEventSink?
let client = VBotPhone.sharedInstance
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
// Cấu hình VBot SDK
let config = VBotConfig(
supportPopupCall: false,
includesCallsInRecents: true,
iconTemplateImageData: UIImage(named: "callkit-icon")?.pngData(),
environment: .production
)
client.setup(with: config)
client.addDelegate(self)
let controller = window?.rootViewController as! FlutterViewController
// MethodChannel
let vbotChannel = FlutterMethodChannel(name: "com.vpmedia.vbot-sdk/vbot_phone", binaryMessenger: controller.binaryMessenger)
vbotChannel.setMethodCallHandler(self.methodCall)
// EventChannel
let callChannel = FlutterEventChannel(name: "com.vpmedia.vbot-sdk/call", binaryMessenger: controller.binaryMessenger)
callChannel.setStreamHandler(self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func methodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "connect":
let token = (call.arguments as? [String: Any])?["token"] as? String ?? ""
client.connect(token: token) { displayName, error in
if let error = error {
result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil))
} else {
result(["displayName": displayName])
}
}
case "disconnect":
client.disconnect { error in
result(["disconnect": error == nil])
}
case "startCall":
let number = (call.arguments as? [String: Any])?["phoneNumber"] as? String ?? ""
let hotline = (call.arguments as? [String: Any])?["hotline"] as? String ?? ""
client.startOutgoingCall(name: "", number: number, hotline: hotline) { resultAPI in
result(["phoneNumber": number])
}
case "answer":
client.answerCall()
case "hangup":
client.endCall()
case "mute":
client.muteCall(enable: true)
case "speaker":
client.speaker(enable: true)
default:
result(FlutterMethodNotImplemented)
}
}
// Lắng nghe sự kiện thay đổi trạng thái từ SDK truyền lên Flutter
func callStateChanged(state: VBotCallState) {
let stateStr: String
switch state {
case .none: stateStr = "none"
case .calling, .early: stateStr = "calling"
case .incoming: stateStr = "incoming"
case .connecting: stateStr = "connecting"
case .confirmed: stateStr = "confirmed"
default: stateStr = "disconnected"
}
eventSink?(["state": stateStr, "isIncoming": client.isIncomingCall()])
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
}3. Quản lý phía Flutter (Dart)
Tạo tệp vbot_phone_manager.dart để bọc và xuất các phương thức ra ngoài để gọi từ giao diện Flutter:
dart
import 'dart:async';
import 'package:flutter/services.dart';
class VBotPhoneManager {
static final VBotPhoneManager _instance = VBotPhoneManager._internal();
factory VBotPhoneManager() => _instance;
VBotPhoneManager._internal();
static const MethodChannel _methodChannel = MethodChannel('com.vpmedia.vbot-sdk/vbot_phone');
static const EventChannel _eventChannel = EventChannel('com.vpmedia.vbot-sdk/call');
final StreamController<Map<String, dynamic>> _callStateController = StreamController<Map<String, dynamic>>.broadcast();
StreamSubscription<dynamic>? _eventSubscription;
Stream<Map<String, dynamic>> get callStateStream => _callStateController.stream;
Future<void> init() async {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
(event) {
final map = Map<String, dynamic>.from(event as Map);
_callStateController.add(map);
},
onError: (e) => print("Stream error: $e"),
);
}
Future<String?> connect(String token) async {
final result = await _methodChannel.invokeMethod('connect', {'token': token});
return (result as Map)['displayName'] as String?;
}
Future<bool> disconnect() async {
final result = await _methodChannel.invokeMethod('disconnect');
return (result as Map)['disconnect'] as bool;
}
Future<String?> startCall(String phoneNumber, String hotline) async {
final result = await _methodChannel.invokeMethod('startCall', {
'phoneNumber': phoneNumber,
'hotline': hotline,
});
return (result as Map)['phoneNumber'] as String?;
}
Future<void> answer() async => await _methodChannel.invokeMethod('answer');
Future<void> hangup() async => await _methodChannel.invokeMethod('hangup');
Future<void> mute() async => await _methodChannel.invokeMethod('mute');
Future<void> speaker() async => await _methodChannel.invokeMethod('speaker');
void dispose() {
_eventSubscription?.cancel();
_callStateController.close();
}
}