Flutter语音通信插件twilio_voice的使用
Flutter语音通信插件twilio_voice的使用
概述
twilio_voice
插件为 Flutter 应用程序提供了与 Twilio 的 Programmable Voice SDK 接口,允许在应用程序中实现基于 IP 的语音通话(VoIP)。以下是该插件的主要功能和使用方法。
特性
- iOS 设备:通过 Callkit 接收和发起通话。
- Android 设备:通过原生通话界面(
ConnectionService
实现)接收和发起通话。 - Web:通过 FCM 推送通知集成(目前不支持)接收和发起通话。
- macOS 设备:通过自定义 UI 接收和发起通话(未来将使用 CallKit)。
- 参数解析:解释 TwiML 参数以填充 UI。
功能添加计划
- 音频设备选择支持(选择输入/输出音频设备,保持通话)
- 更新插件以支持 Flutter 联邦包(第一步是合并 Web 支持)
- 桌面平台支持(实现为 JS 包装器/原生实现,先从 Windows 和 Linux 开始)
平台限制
Android 限制
- Android 提供了原生的
ConnectionService
来处理来电,但目前 Twilio 的ConnectionService
实现尚未完全实现。
macOS 限制
- macOS 13.0+ 才有 CallKit 支持,目前尚未实现。
- 使用 Twilio Voice Web SDK (
twilio-voice.js
) 提供功能,但不支持远程推送通知.voip
和.apns
,而是使用 WebSocket 连接来监听来电,这需要应用始终处于打开状态。
设置
iOS 设置
- 在 Xcode 中打开项目,将一个名为
callkit_icon
的 PNG 图标添加到assets.xassets
文件夹中。
macOS 设置
-
在
Info.plist
文件中添加以下内容:<key>NSMicrophoneUsageDescription</key> <string>Allow microphone access to make calls</string>
-
包含 Hardened Runtime 权限:
<key>com.apple.security.audio-input</key> <true/> <key>com.apple.security.device.bluetooth</key> <true/>
-
确保
index.html
和twilio.min.js
文件包含在twilio_voice
包中。
Android 设置
-
在
AndroidManifest.xml
中注册服务:<service android:name="com.twilio.twilio_voice.fcm.VoiceFirebaseMessagingService" android:stopWithTask="false"> <intent-filter> <action android:name="com.google.firebase.MESSAGING_EVENT" /> </intent-filter> </service>
-
请求读取电话号码权限:
TwilioVoice.instance.requestReadPhoneNumbersPermission();
-
注册
PhoneAccount
:TwilioVoice.instance.registerPhoneAccount();
-
打开
PhoneAccount
设置:TwilioVoice.instance.openPhoneAccountSettings();
-
检查
PhoneAccount
是否启用:TwilioVoice.instance.isPhoneAccountEnabled();
-
请求通话权限:
TwilioVoice.instance.requestCallPhonePermission();
Web 设置
-
复制文件
example/web/notifications.js
和example/web/twilio.min.js
到你的应用的web
文件夹中。 -
构建应用后,复制
example/web/twilio-sw.js
的内容到build/web/flutter_service_worker.js
文件的末尾。 -
在
index.html
文件的<body>
标签末尾添加以下代码:<script type="text/javascript" src="./twilio.min.js"></script>
使用示例
主要代码
import 'dart:async';
import 'dart:io';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:twilio_voice/twilio_voice.dart';
import 'package:twilio_voice_example/screens/ui_call_screen.dart';
import 'package:twilio_voice_example/screens/ui_registration_screen.dart';
import 'api.dart';
import 'utils.dart';
extension IterableExtension<E> on Iterable<E> {
E? firstWhereOrNull(bool Function(E element) test, {E Function()? orElse}) {
for (E element in this) {
if (test(element)) return element;
}
return (orElse == null) ? null : orElse();
}
}
enum RegistrationMethod {
env,
local,
firebase;
static RegistrationMethod? fromString(String? value) {
if (value == null) return null;
return RegistrationMethod.values.firstWhereOrNull((element) => element.name == value);
}
static RegistrationMethod? loadFromEnvironment() {
const value = String.fromEnvironment("REGISTRATION_METHOD");
return fromString(value);
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (kIsWeb) {
const options = FirebaseOptions(
apiKey: '',
appId: '',
messagingSenderId: '',
projectId: '',
authDomain: '',
databaseURL: '',
storageBucket: '',
measurementId: '',
);
await Firebase.initializeApp(options: options).catchError((error) {
printDebug("Failed to initialise firebase $error");
});
} else {
await Firebase.initializeApp();
}
final app = App(registrationMethod: RegistrationMethod.loadFromEnvironment() ?? RegistrationMethod.env);
return runApp(MaterialApp(home: app));
}
class App extends StatefulWidget {
final RegistrationMethod registrationMethod;
const App({super.key, this.registrationMethod = RegistrationMethod.local});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
String userId = "";
bool twilioInit = false;
var authRegistered = false;
var showingIncomingCallDialog = false;
void register() async {
printDebug("voip-service registration");
if (!kIsWeb) {
bool success = false;
switch (widget.registrationMethod) {
case RegistrationMethod.env:
success = await _registerFromEnvironment();
break;
case RegistrationMethod.local:
success = await _registerLocal();
break;
case RegistrationMethod.firebase:
success = await _registerFirebase();
break;
}
if (success) {
setState(() {
twilioInit = true;
});
}
} else {
}
}
Future<bool> _registerAccessToken(String accessToken) async {
printDebug("voip-registering access token");
String? androidToken;
if (!kIsWeb && Platform.isAndroid) {
androidToken = await FirebaseMessaging.instance.getToken();
printDebug("androidToken is ${androidToken!}");
}
final result = await TwilioVoice.instance.setTokens(accessToken: accessToken, deviceToken: androidToken);
return result ?? false;
}
Future<bool> _registerFromEnvironment() async {
String? myId = const String.fromEnvironment("ID");
String? myToken = const String.fromEnvironment("TOKEN");
if (myId.isEmpty) myId = null;
if (myToken.isEmpty) myToken = null;
printDebug("voip-registering with environment variables");
if (myId == null || myToken == null) {
printDebug("Failed to register with environment variables, please provide ID and TOKEN");
return false;
}
userId = myId;
return _registerAccessToken(myToken);
}
Future<bool> _registerFromCredentials(String identity, String token) async {
userId = identity;
return _registerAccessToken(token);
}
Future<bool> _registerLocal() async {
printDebug("voip-registering with local token generator");
final result = await generateLocalAccessToken();
if (result == null) {
printDebug("Failed to register with local token generator");
return false;
}
userId = result.identity;
return _registerAccessToken(result.accessToken);
}
void _listenForFirebaseLogin() {
final auth = FirebaseAuth.instance;
auth.authStateChanges().listen((user) async {
if (user == null) {
await auth.signInAnonymously();
} else if (!authRegistered) {
authRegistered = true;
if (userId.isEmpty) {
userId = user.uid;
}
printDebug("registering client $userId [firebase id ${user.uid}]");
_registerFirebase();
}
});
}
Future<bool> _registerFirebase() async {
if (!authRegistered) {
_listenForFirebaseLogin();
return false;
}
printDebug("voip-registering with firebase token generator");
final result = await generateFirebaseAccessToken();
if (result == null) {
printDebug("Failed to register with firebase token generator");
return false;
}
userId = result.identity;
return _registerAccessToken(result.accessToken);
}
@override
void initState() {
super.initState();
TwilioVoice.instance.setOnDeviceTokenChanged((token) {
printDebug("voip-device token changed");
if (!kIsWeb) {
register();
}
});
listenForEvents();
register();
const partnerId = "alicesId";
TwilioVoice.instance.registerClient(partnerId, "Alice");
}
void listenForEvents() {
TwilioVoice.instance.callEventsListener.listen((event) {
printDebug("voip-onCallStateChanged $event");
switch (event) {
case CallEvent.incoming:
if (kIsWeb || Platform.isAndroid) {
final activeCall = TwilioVoice.instance.call.activeCall;
if (activeCall != null && activeCall.callDirection == CallDirection.incoming) {
_showWebIncomingCallDialog();
}
}
break;
case CallEvent.ringing:
final activeCall = TwilioVoice.instance.call.activeCall;
if (activeCall != null) {
final customData = activeCall.customParams;
if (customData != null) {
printDebug("voip-customData $customData");
}
}
break;
case CallEvent.connected:
case CallEvent.callEnded:
case CallEvent.declined:
case CallEvent.answer:
if (kIsWeb || Platform.isAndroid) {
final nav = Navigator.of(context);
if (nav.canPop() && showingIncomingCallDialog) {
nav.pop();
showingIncomingCallDialog = false;
}
}
break;
default:
break;
}
});
}
Future<void> _onPerformCall(String clientIdentifier) async {
if (!await (TwilioVoice.instance.hasMicAccess())) {
printDebug("request mic access");
TwilioVoice.instance.requestMicAccess();
return;
}
printDebug("starting call to $clientIdentifier");
TwilioVoice.instance.call.place(to: clientIdentifier, from: userId, extraOptions: {"_TWI_SUBJECT": "Company Name"});
}
Future<void> _onRegisterWithToken(String token, [String? identity]) async {
return _registerFromCredentials(identity ?? "Unknown", token).then((value) {
if (!value) {
showDialog(
context: context,
builder: (context) => const AlertDialog(
title: Text("Error"),
content: Text("Failed to register for calls"),
),
);
} else {
setState(() {
twilioInit = true;
});
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Plugin example app"),
actions: [
_LogoutAction(
onSuccess: () {
setState(() {
twilioInit = false;
});
},
onFailure: (error) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("Error"),
content: Text("Failed to unregister from calls: $error"),
),
);
},
),
],
),
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: twilioInit
? UICallScreen(
userId: userId,
onPerformCall: _onPerformCall,
)
: UIRegistrationScreen(
onRegister: _onRegisterWithToken,
),
),
),
),
);
}
void _showWebIncomingCallDialog() async {
showingIncomingCallDialog = true;
final activeCall = TwilioVoice.instance.call.activeCall!;
final action = await showIncomingCallScreen(context, activeCall);
if (action == true) {
printDebug("accepting call");
TwilioVoice.instance.call.answer();
} else if (action == false) {
printDebug("rejecting call");
TwilioVoice.instance.call.hangUp();
} else {
printDebug("no action");
}
}
Future<bool?> showIncomingCallScreen(BuildContext context, ActiveCall activeCall) async {
if (!kIsWeb && !Platform.isAndroid) {
printDebug("showIncomingCallScreen only for web");
return false;
}
return showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text("Incoming Call"),
content: Text("Incoming call from ${activeCall.from}"),
actions: [
TextButton(
child: const Text("Accept"),
onPressed: () {
Navigator.of(context).pop(true);
},
),
TextButton(
child: const Text("Reject"),
onPressed: () {
Navigator.of(context).pop(false);
},
),
],
);
},
);
}
}
class _LogoutAction extends StatelessWidget {
final void Function()? onSuccess;
final void Function(String error)? onFailure;
const _LogoutAction({Key? key, this.onSuccess, this.onFailure}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: () async {
final result = await TwilioVoice.instance.unregister();
if (result == true) {
onSuccess?.call();
} else {
onFailure?.call("Failed to unregister");
}
},
label: const Text("Logout", style: TextStyle(color: Colors.white)),
icon: const Icon(Icons.logout, color: Colors.white));
}
}
云函数设置
创建 TwiML 应用
- 登录 Twilio 账户。
- 创建一个新的 TwiML 应用:
twilio api:core:applications:create \ --friendly-name=my-twiml-app \ --voice-method=POST \ --voice-url="https://my-quickstart-dev.twil.io/make-call"
配置环境
- 确保你有一个
.env
文件,并编辑它:ACCOUNT_SID=(insert account SID) APP_SID=(insert App SID, found on TwiML app or the APxxxxx key above)
生成推送凭证
Android
- 获取 FCM 服务器密钥:
twilio api:chat:v2:credentials:create \ --type=fcm \ --friendly-name="voice-push-credential-fcm" \ --secret=SERVER_KEY_VALUE
iOS
-
导出 VoIP 服务证书为 .p12 文件,并提取证书和私钥:
openssl pkcs12 -in PATH_TO_YOUR_P12 -nokeys -out cert.pem -nodes openssl pkcs12 -in PATH_TO_YOUR_P12 -nocerts -out key.pem -nodes openssl rsa -in key.pem -out key.pem
-
生成凭证:
twilio api:chat:v2:credentials:create \ --type=apn \ --sandbox \ --friendly-name="voice-push-credential (sandbox)" \ --certificate="$(cat PATH_TO_SANDBOX_CERT_PEM)" \ --private-key="$(cat PATH_TO_SANDBOX_KEY_PEM)"
生成访问令牌
-
创建 Firebase 函数:
const { AccessToken } = require('twilio').jwt; const functions = require('firebase-functions'); const { VoiceGrant } = AccessToken; exports.accessToken = functions.https.onCall((payload, context) => { if (typeof (context.auth) === 'undefined') { throw new functions.https.HttpsError('unauthenticated', 'The function must be called while authenticated'); } let userId = context.auth.uid; console.log('creating access token for ', userId); const twilioConfig = functions.config().twilio; const accountSid = twilioConfig.account_sid; const apiKey = twilioConfig.api_key; const apiSecret = twilioConfig.api_key_secret; const outgoingApplicationSid = twilioConfig.app_sid; let pushCredSid; if (payload.isIOS === true) { console.log('creating access token for iOS'); pushCredSid = payload.production ? twilioConfig.apple_push_credential_release : (twilioConfig.apple_push_credential_debug || twilioConfig.apple_push_credential_release); } else if (payload.isAndroid === true) { console.log('creating access token for Android'); pushCredSid = twilioConfig.android_push_credential; } else { throw new functions.https.HttpsError('unknown_platform', 'No platform specified'); } const dateTime = new Date(); dateTime.setDate(dateTime.getDate() + 1); const voiceGrant = new VoiceGrant({ outgoingApplicationSid, pushCredentialSid: pushCredSid, }); const token = new AccessToken(accountSid, apiKey, apiSecret); token.addGrant(voiceGrant); token.identity = userId; console.log(`Token:${token.toJwt()}`); return { "token": token.toJwt(), "identity": userId, "expiry_date": dateTime.getTime() }; });
-
部署 Firebase 函数:
firebase deploy --only functions
未来工作
- 将包迁移到
federated plugin
结构 - 更新 Twilio Voice JS SDK
希望这些信息对你有所帮助!如果有任何问题或需要进一步的帮助,请随时提问。
更多关于Flutter语音通信插件twilio_voice的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
更多关于Flutter语音通信插件twilio_voice的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
当然,下面是一个关于如何在Flutter项目中使用twilio_voice
插件进行语音通信的代码示例。这个示例将涵盖基本的初始化、建立呼叫以及处理呼叫事件。
首先,确保你已经在pubspec.yaml
文件中添加了twilio_voice
依赖:
dependencies:
flutter:
sdk: flutter
twilio_voice: ^0.6.0 # 请检查最新版本号
然后运行flutter pub get
来安装依赖。
1. 配置Twilio凭证
在Flutter项目的根目录下创建一个名为twilio_config.dart
的文件,用于存储Twilio的Account SID、API Key、API Secret和Identity等配置信息。
// twilio_config.dart
object-oriented programming class TwilioConfig {
static const String accountSid = '你的Account SID';
static const String apiKey = '你的API Key';
static const String apiSecret = '你的API Secret';
static const String identity = '用户的唯一标识';
static const String accessTokenUrl = 'https://你的服务器URL/twilio_token'; // 用于获取Access Token的服务器端点
}
2. 初始化Twilio Voice
在你的主应用文件(通常是main.dart
)中,初始化Twilio Voice并处理呼叫事件。
import 'package:flutter/material.dart';
import 'package:twilio_voice/twilio_voice.dart';
import 'twilio_config.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: TwilioVoiceScreen(),
);
}
}
class TwilioVoiceScreen extends StatefulWidget {
@override
_TwilioVoiceScreenState createState() => _TwilioVoiceScreenState();
}
class _TwilioVoiceScreenState extends State<TwilioVoiceScreen> {
TwilioVoice? _twilioVoice;
bool _isCalling = false;
@override
void initState() {
super.initState();
initTwilioVoice();
}
void initTwilioVoice() async {
_twilioVoice = TwilioVoice(
accessToken: await fetchAccessToken(),
identity: TwilioConfig.identity,
enableIncoming: true,
enableOutgoing: true,
logger: (level, message) {
print('TwilioVoice: $level - $message');
},
onCallInvited: (callInvite) async {
// 处理呼入邀请
setState(() {
_isCalling = true;
});
await callInvite.accept();
},
onCallStarted: (call) {
// 处理呼叫开始事件
print('Call started');
},
onCallEnded: (call) {
// 处理呼叫结束事件
setState(() {
_isCalling = false;
});
print('Call ended');
},
onError: (error) {
// 处理错误
print('Error: $error');
},
);
}
Future<String> fetchAccessToken() async {
// 从服务器获取Access Token
final response = await http.get(Uri.parse(TwilioConfig.accessTokenUrl));
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Failed to fetch access token');
}
}
void makeCall() async {
if (_twilioVoice == null || _isCalling) return;
setState(() {
_isCalling = true;
});
try {
await _twilioVoice!.connect({
'To': '目标电话号码或Twilio客户端标识',
});
} catch (e) {
print('Failed to make call: $e');
setState(() {
_isCalling = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Twilio Voice Example'),
),
body: Center(
child: ElevatedButton(
onPressed: _isCalling ? null : makeCall,
child: Text('Make Call'),
),
),
);
}
@override
void dispose() {
_twilioVoice?.dispose();
super.dispose();
}
}
3. 配置服务器端获取Access Token
确保你有一个服务器端点(如Node.js、Python等)来生成Twilio Access Token。以下是一个简单的Node.js示例:
const express = require('express');
const twilio = require('twilio');
const app = express();
const port = 3000;
const accountSid = '你的Account SID';
const apiKeySid = '你的API Key SID';
const apiKeySecret = '你的API Key Secret';
const identity = '用户的唯一标识';
const accessToken = new twilio.AccessToken(accountSid, apiKeySid, apiKeySecret, { identity: identity });
accessToken.addGrant(new twilio.VoiceGrant({
outgoingApplicationSid: '你的Twilio应用SID', // 替换为你的Twilio Voice应用SID
incomingAllow: true,
}));
app.get('/twilio_token', (req, res) => {
res.type('application/jwt');
res.send(accessToken.toJwt());
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}/`);
});
总结
这个示例展示了如何在Flutter项目中使用twilio_voice
插件进行基本的语音通信。包括初始化Twilio Voice、处理呼入和呼出事件以及从服务器获取Access Token。你需要根据你的具体需求进一步调整和扩展这个示例。