💭

[Flutter] riverpod / go_router / sip_ua で音声通話

2022/11/06に公開

FlutterとSIPを使って『普通の』電話を作っていく。

まずはサーバーを準備する。

asteriskでWebRTCサーバーを立てる。

https://zenn.dev/yoheikusano/articles/88e7d075487b93

次に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