💭
[Flutter] riverpod / go_router / sip_ua で音声通話
FlutterとSIPを使って『普通の』電話を作っていく。
まずはサーバーを準備する。
asteriskでWebRTCサーバーを立てる。
次にFlutterの準備
新しいプロジェクト準備と必要なライブラリなど。
flutter create --org com.example sip_ua_test
pubspec.yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
sip_ua: ^0.5.3
hooks_riverpod: ^2.0.2
json_annotation: ^4.7.0
freezed_annotation: ^2.2.0
shared_preferences: ^0.5.5
go_router: ^5.1.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
build_runner: ^2.3.2
freezed: ^2.2.1
json_serializable: ^6.5.4
フォルダ構成
フォルダはこんな感じです。
│ main.dart
│ sip_ua_provider.dart
│
├─screens
│ call_screen.dart
│ incoming_screen.dart
│ register_screen.dart
│
└─utils
router.dart
AndroidManifest.xml
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.sip_ua_test">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
他のコード
今回は発着信と音声通話のみのシンプルな例です。ちゃんと電話として扱いたい場合はダイヤルパッドの実装やcallkeepによる着信によるスリープ復帰などが別途必要となると思います。
main.dart
エンドポイントの定義のみ
main.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sip_ua_test/utils/router.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
// This widget is the root of your application.
Widget build(BuildContext context, WidgetRef ref) {
final _router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routeInformationParser: _router.routeInformationParser,
routeInformationProvider: _router.routeInformationProvider,
routerDelegate: _router.routerDelegate,
);
}
}
sip_ua_provider.dart
本体。
紹介して作成したAsteriskのipアドレスをwss://[asteriskIPAddress]:8089/ws
という形で入れます。URI注意。
sip_ua_provider.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sip_ua/sip_ua.dart';
final callStateEnumProvider =
StateProvider<CallStateEnum>((ref) => CallStateEnum.NONE);
final registrationStateEnumProvider =
StateProvider<RegistrationStateEnum>(((ref) => RegistrationStateEnum.NONE));
final sipUAHelperProvider = StateProvider<SIPUAHelper>((ref) => SIPUAHelper());
final callProvider = StateProvider<Call?>(
(ref) {
return null;
},
);
final uaSettingsProvider = StateProvider<UaSettings>((ref) {
final _settings = UaSettings();
_settings.webSocketUrl = "wss://[asteriskIPAddress]:8089/ws";
_settings.webSocketSettings.extraHeaders = {"": ""};
_settings.webSocketSettings.allowBadCertificate = true;
_settings.uri = "info@example.com";
_settings.authorizationUser = "400";
_settings.password = "400";
_settings.displayName = "400";
_settings.userAgent = 'Dart SIP Client v1.0.0';
_settings.dtmfMode = DtmfMode.RFC2833;
return _settings;
});
final sipUAHelperNotifierProvider =
ChangeNotifierProvider<SIPUAHelperNotifier>((ref) {
final helper = ref.watch(sipUAHelperProvider);
final helperNotifier = SIPUAHelperNotifier(ref: ref);
ref.onAddListener(() {
print("add listener");
helper.addSipUaHelperListener(helperNotifier);
});
ref.onDispose(() {
print('remove listener');
helper.removeSipUaHelperListener(helperNotifier);
});
// helper.start(settings);
return helperNotifier;
}, dependencies: [sipUAHelperProvider, uaSettingsProvider]);
class SIPUAHelperNotifier extends ChangeNotifier
implements SipUaHelperListener {
SIPUAHelperNotifier({required this.ref}) : super();
final Ref ref;
void start() {
final helper = ref.watch(sipUAHelperProvider);
final settings = ref.watch(uaSettingsProvider);
helper.start(settings);
}
void callStateChanged(Call call, CallState callState) {
ref.read(callStateEnumProvider.notifier).update((state) => callState.state);
ref.read(callProvider.notifier).update(
(state) => call,
);
notifyListeners();
}
void registrationStateChanged(RegistrationState registrationState) {
// print('register state : ${state.state}');
final registrationStateNotifier =
ref.watch(registrationStateEnumProvider.notifier).update((state) {
print(" state is changed // ${registrationState.state}");
return registrationState.state!;
});
notifyListeners();
}
void transportStateChanged(TransportState state) {
// TODO: implement transportStateChanged
}
void onNewMessage(SIPMessageRequest msg) {
// TODO: implement onNewMessage
}
void onNewNotify(Notify ntf) {
// TODO: implement onNewNotify
}
}
router.dart
redirect
でRegistrationStateEnumがRegisterになっているときだけ行けるようにします。
router.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sip_ua/sip_ua.dart';
import 'package:sip_ua_test/screens/call_screen.dart';
import 'package:sip_ua_test/screens/incoming_screen.dart';
import 'package:sip_ua_test/screens/register_screen.dart';
import 'package:sip_ua_test/sip_ua_provider.dart';
final routerProvider = Provider<GoRouter>((ref) {
final registrationStateEnum = ref.watch(registrationStateEnumProvider);
return GoRouter(
routes: [
GoRoute(
name: 'top',
path: '/',
builder: ((context, state) => const CallScreen())),
GoRoute(
name: 'register',
path: '/register',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
name: 'incoming',
path: '/incoming',
builder: (context, state) => const IncomingScreen(),
)
],
redirect: ((context, state) {
if (registrationStateEnum != RegistrationStateEnum.REGISTERED) {
return state.subloc == '/register' ? null : '/register';
}
return null;
}),
);
}, dependencies: [registrationStateEnumProvider, callStateEnumProvider]);
call_screen.dart
call_screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sip_ua/sip_ua.dart';
import 'package:sip_ua_test/sip_ua_provider.dart';
import 'package:go_router/go_router.dart';
class CallScreenBody extends StatefulHookConsumerWidget {
const CallScreenBody({super.key});
ConsumerState<ConsumerStatefulWidget> createState() => _CallScreenBodyState();
}
class _CallScreenBodyState extends ConsumerState<CallScreenBody> {
Widget build(BuildContext context) {
return Container();
}
}
class CallScreen extends ConsumerWidget {
const CallScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final sipUaHelper = ref.watch(sipUAHelperProvider);
final _sipUaHelperNotifier = ref.watch(sipUAHelperNotifierProvider);
ref.listen(callStateEnumProvider, (previousState, nextState) {
if (nextState == CallStateEnum.CALL_INITIATION) {
context.go('/incoming');
}
});
return Scaffold(
appBar: AppBar(title: const Text('dial screen')),
body: Center(
child: Column(
children: [
const Text('please push call button'),
OutlinedButton(
onPressed: () {
sipUaHelper.call("300", voiceonly: true);
},
child: const Text('call'))
],
)),
);
}
}
incoming_screen.dart
incoming_screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sip_ua/sip_ua.dart';
import 'package:sip_ua_test/sip_ua_provider.dart';
import 'package:go_router/go_router.dart';
class IncomingScreen extends ConsumerWidget {
const IncomingScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final _call = ref.watch(callProvider);
final _helper = ref.watch(sipUAHelperProvider);
final _helperNotifier = ref.watch(sipUAHelperNotifierProvider);
final _callState = ref.watch(callStateEnumProvider);
ref.listen(callStateEnumProvider, ((previous, next) {
if (next == CallStateEnum.FAILED || next == CallStateEnum.ENDED) {
context.goNamed('top');
}
}));
return Scaffold(
appBar: AppBar(title: const Text('Calling ')),
body: Column(children: [
Text('Call from ${_call?.remote_display_name ?? "outgoing"} '),
if (_callState == CallStateEnum.PROGRESS)
OutlinedButton(
onPressed: () {
_call?.answer(_helper.buildCallOptions(true));
},
child: const Text('answer')),
OutlinedButton(
onPressed: () {
_call?.hangup();
},
child: const Text('quit talking'))
]),
);
}
}
register_screen.dart
register_screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sip_ua/sip_ua.dart';
import 'package:sip_ua_test/sip_ua_provider.dart';
class RegisterScreen extends ConsumerWidget {
const RegisterScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final helperNotifierProvider = ref.watch(sipUAHelperNotifierProvider);
ref.listen(registrationStateEnumProvider, (previousState, nextState) {
if (nextState == RegistrationStateEnum.REGISTERED) {
context.goNamed('top');
}
});
return Scaffold(
appBar: AppBar(title: const Text('Register Screen')),
body: Center(
child: Column(
children: [
const Text('push button below'),
OutlinedButton(
onPressed: () {
ref.read(sipUAHelperNotifierProvider.notifier).start();
},
child: const Text('register'))
],
)),
);
}
}
最後に
今はSIP使うよりもtencent realtime communicationなどを使ったほうがはやいかもしれませんが、固定電話や携帯電話との連携を考えた場合、やはりSIPが使いやすいと思いました。
Discussion