Flutter语音通信插件twilio_voice的使用

发布于 1周前 作者 h691938207 来自 Flutter

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 设置

  1. 在 Xcode 中打开项目,将一个名为 callkit_icon 的 PNG 图标添加到 assets.xassets 文件夹中。

macOS 设置

  1. Info.plist 文件中添加以下内容:

    <key>NSMicrophoneUsageDescription</key>
    <string>Allow microphone access to make calls</string>
    
  2. 包含 Hardened Runtime 权限:

    <key>com.apple.security.audio-input</key>
    <true/>
    <key>com.apple.security.device.bluetooth</key>
    <true/>
    
  3. 确保 index.htmltwilio.min.js 文件包含在 twilio_voice 包中。

Android 设置

  1. 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>
    
  2. 请求读取电话号码权限:

    TwilioVoice.instance.requestReadPhoneNumbersPermission();
    
  3. 注册 PhoneAccount

    TwilioVoice.instance.registerPhoneAccount();
    
  4. 打开 PhoneAccount 设置:

    TwilioVoice.instance.openPhoneAccountSettings();
    
  5. 检查 PhoneAccount 是否启用:

    TwilioVoice.instance.isPhoneAccountEnabled();
    
  6. 请求通话权限:

    TwilioVoice.instance.requestCallPhonePermission();
    

Web 设置

  1. 复制文件 example/web/notifications.jsexample/web/twilio.min.js 到你的应用的 web 文件夹中。

  2. 构建应用后,复制 example/web/twilio-sw.js 的内容到 build/web/flutter_service_worker.js 文件的末尾。

  3. 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 应用

  1. 登录 Twilio 账户。
  2. 创建一个新的 TwiML 应用:
    twilio api:core:applications:create \
    --friendly-name=my-twiml-app \
    --voice-method=POST \
    --voice-url="https://my-quickstart-dev.twil.io/make-call"
    

配置环境

  1. 确保你有一个 .env 文件,并编辑它:
    ACCOUNT_SID=(insert account SID)
    APP_SID=(insert App SID, found on TwiML app or the APxxxxx key above)
    

生成推送凭证

Android

  1. 获取 FCM 服务器密钥:
    twilio api:chat:v2:credentials:create \
    --type=fcm \
    --friendly-name="voice-push-credential-fcm" \
    --secret=SERVER_KEY_VALUE
    

iOS

  1. 导出 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
    
  2. 生成凭证:

    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)"
    

生成访问令牌

  1. 创建 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()
      };
    });
    
  2. 部署 Firebase 函数:

    firebase deploy --only functions
    

未来工作

  • 将包迁移到 federated plugin 结构
  • 更新 Twilio Voice JS SDK

希望这些信息对你有所帮助!如果有任何问题或需要进一步的帮助,请随时提问。


更多关于Flutter语音通信插件twilio_voice的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于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。你需要根据你的具体需求进一步调整和扩展这个示例。

回到顶部