From 0377bfd5b28ba3f1f7e793548cd088c7445d3a05 Mon Sep 17 00:00:00 2001 From: Mikael Wills <63661422+mikaelwills@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:49:45 +0100 Subject: [PATCH] Extracting UI responsibility, named parameters, android dismissal fix (#189) Co-authored-by: Mikael Wills --- README.md | 89 ++++++++---- .../io/wazo/callkeep/VoiceConnection.java | 1 + example/lib/main.dart | 121 +++++++++------- ios/Classes/CallKeep.m | 1 - lib/src/api.dart | 134 ++++++------------ lib/src/event.dart | 11 +- 6 files changed, 187 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index 30137f15..28586482 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![Financial Contributors on Open Collective](https://opencollective.com/flutter-webrtc/all/badge.svg?label=financial+contributors)](https://opencollective.com/flutter-webrtc) [![pub package](https://img.shields.io/pub/v/callkeep.svg)](https://pub.dartlang.org/packages/callkeep) [![slack](https://img.shields.io/badge/join-us%20on%20slack-gray.svg?longCache=true&logo=slack&colorB=brightgreen)](https://join.slack.com/t/flutterwebrtc/shared_invite/zt-q83o7y1s-FExGLWEvtkPKM8ku_F8cEQ) -* iOS CallKit and Android ConnectionService for Flutter -* Support FCM and PushKit +- iOS CallKit and Android ConnectionService for Flutter +- Support FCM and PushKit > Keep in mind Callkit is banned in China, so if you want your app in the chinese AppStore consider include a basic alternative for notifying calls (ex. FCM notifications with sound). @@ -36,7 +36,7 @@ final callSetup = { 'channelName': 'Foreground service for my app', 'notificationTitle': 'My app is running on background', 'notificationIcon': 'mipmap/ic_notification_launcher', - }, + }, }, }; @@ -53,7 +53,7 @@ Callkeep offers some events to handle native actions during a call. These events are quite crucial because they act as an intermediate between the native calling UI and your call P-C-M. -What does it mean? +What does it mean? Assuming your application already implements some calling system (RTC, Voip, or whatever) with its own calling UI, you are using some basic controls: @@ -72,32 +72,37 @@ Assuming your application already implements some calling system (RTC, Voip, or Then you handle the action: ```dart -Function(CallKeepPerformAnswerCallAction) answerAction = (event) async { +Future answerCall(CallKeepPerformAnswerCallAction event) async { print('CallKeepPerformAnswerCallAction ${event.callUUID}'); // notify to your call P-C-M the answer action }; -Function(CallKeepPerformEndCallAction) endAction = (event) async { + Future endCall(CallKeepPerformEndCallAction event) async { print('CallKeepPerformEndCallAction ${event.callUUID}'); // notify to your call P-C-M the end action }; -Function(CallKeepDidPerformSetMutedCallAction) setMuted = (event) async { +Future didPerformSetMutedCallAction(CallKeepDidPerformSetMutedCallAction event) async { print('CallKeepDidPerformSetMutedCallAction ${event.callUUID}'); // notify to your call P-C-M the muted switch action }; -Function(CallKeepDidToggleHoldAction) onHold = (event) async { + Future didToggleHoldCallAction(CallKeepDidToggleHoldAction event) async { print('CallKeepDidToggleHoldAction ${event.callUUID}'); // notify to your call P-C-M the hold switch action }; ``` ```dart -callKeep.on(CallKeepDidToggleHoldAction(), onHold); -callKeep.on(CallKeepPerformAnswerCallAction(), answerAction); -callKeep.on(CallKeepPerformEndCallAction(), endAction); -callKeep.on(CallKeepDidPerformSetMutedCallAction(), setMuted); + + @override + void initState() { + super.initState(); + callKeep.on(didDisplayIncomingCall); + callKeep.on(answerCall); + callKeep.on(endCall); + callKeep.on(didToggleHoldCallAction); + } ``` ## Display incoming calls in foreground, background or terminate state @@ -120,7 +125,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { print(e); } } - + // then process your remote message looking for some call uuid // and display any incoming call } @@ -134,11 +139,11 @@ A payload data example: ```json { - "uuid": "xxxxx-xxxxx-xxxxx-xxxxx", - "caller_id": "+0123456789", - "caller_name": "Draco", - "caller_id_type": "number", - "has_video": "false" + "uuid": "xxxxx-xxxxx-xxxxx-xxxxx", + "caller_id": "+0123456789", + "caller_name": "Draco", + "caller_id_type": "number", + "has_video": "false" } ``` @@ -172,11 +177,11 @@ Future showIncomingCall( var callerName = remoteMessage.payload()["caller_name"] as String; var uuid = remoteMessage.payload()["uuid"] as String; var hasVideo = remoteMessage.payload()["has_video"] == "true"; - - callKeep.on(CallKeepDidToggleHoldAction(), onHold); - callKeep.on(CallKeepPerformAnswerCallAction(), answerAction); - callKeep.on(CallKeepPerformEndCallAction(), endAction); - callKeep.on(CallKeepDidPerformSetMutedCallAction(), setMuted); + + callKeep.on(onHold); + callKeep.on(answerAction); + callKeep.on(endAction); + callKeep.on(setMuted); print('backgroundMessage: displayIncomingCall ($uuid)'); @@ -207,6 +212,40 @@ Future closeIncomingCall( } ``` +Pass in your own dialog UI for permissions alerts + +````dart +showAlertDialog: () async { + final BuildContext context = navigatorKey.currentContext!; + + return await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Permissions Required'), + content: const Text( + 'This application needs to access your phone accounts'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(false), + ), + TextButton( + child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ); + }, + ) ?? + false; + }, +``` + + + + ### FAQ > I don't receive the incoming call @@ -216,9 +255,9 @@ Remember FCM push messages not always works due to data-only messages are classi > How can I manage the call if the app is terminated and the device is locked? -Even in this scenario, the `backToForeground()` method will open the app and your call P-C-M will be able to work. - +Even in this scenario, the `backToForeground()` method will open the app and your call P-C-M will be able to work. ## push test tool Please refer to the [Push Toolkit](/tools/) to test callkeep offline push. +```` diff --git a/android/src/main/java/io/wazo/callkeep/VoiceConnection.java b/android/src/main/java/io/wazo/callkeep/VoiceConnection.java index 9f836565..a7e766fa 100644 --- a/android/src/main/java/io/wazo/callkeep/VoiceConnection.java +++ b/android/src/main/java/io/wazo/callkeep/VoiceConnection.java @@ -154,6 +154,7 @@ public void onAnswer(int videoState) { private void onAnswered() { initCall(); + setCurrent(); sendCallRequestToActivity(ACTION_ANSWER_CALL, connectionData); } diff --git a/example/lib/main.dart b/example/lib/main.dart index c97cf0b4..0e7f6d1b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -57,34 +57,32 @@ Future myBackgroundMessageHandler(RemoteMessage message) { }); if (!_callKeepInited) { _callKeep.setup( - null, - { - 'ios': { - 'appName': 'CallKeepDemo', - }, - 'android': { - 'alertTitle': 'Permissions required', - 'alertDescription': - 'This application needs to access your phone accounts', - 'cancelButton': 'Cancel', - 'okButton': 'ok', - 'foregroundService': { - 'channelId': 'com.company.my', - 'channelName': 'Foreground service for my app', - 'notificationTitle': 'My app is running on background', - 'notificationIcon': - 'Path to the resource icon of the notification', - }, + showAlertDialog: null, + options: { + 'ios': { + 'appName': 'CallKeepDemo', + }, + 'android': { + 'additionalPermissions': [ + 'android.permission.CALL_PHONE', + 'android.permission.READ_PHONE_NUMBERS' + ], + 'foregroundService': { + 'channelId': 'com.example.call-kit-test', + 'channelName': 'callKitTest', + 'notificationTitle': 'My app is running on background', + 'notificationIcon': 'Path to the resource icon of the notification', }, }, - backgroundMode: true); + }, + ); _callKeepInited = true; } logger.d('backgroundMessage: displayIncomingCall ($callerId)'); _callKeep.displayIncomingCall( - callUUID, - callerId, + uuid: callUUID, + handle: callerId, callerName: callerName, hasVideo: hasVideo, ); @@ -220,7 +218,8 @@ class MyAppState extends State { logger .d('[didReceiveStartCallAction] $callUUID, number: ${callData.handle}'); - _callKeep.startCall(callUUID, call.number, call.number); + _callKeep.startCall( + uuid: callUUID, handle: call.number, callerName: call.number); Timer(const Duration(seconds: 1), () { logger.d('[setCurrentCallActive] $callUUID, number: ${callData.handle}'); @@ -263,14 +262,14 @@ class MyAppState extends State { } Future setOnHold(String callUUID, bool held) async { - _callKeep.setOnHold(callUUID, held); + _callKeep.setOnHold(uuid: callUUID, shouldHold: held); final String handle = calls[callUUID]?.number ?? "No Number"; logger.d('[setOnHold: $held] $callUUID, number: $handle'); setCallHeld(callUUID, held); } Future setMutedCall(String callUUID, bool muted) async { - _callKeep.setMutedCall(callUUID, muted); + _callKeep.setMutedCall(uuid: callUUID, shouldMute: muted); final String handle = calls[callUUID]?.number ?? "No Number"; logger.d('[setMutedCall: $muted] $callUUID, number: $handle'); setCallMuted(callUUID, muted); @@ -280,9 +279,11 @@ class MyAppState extends State { final String number = calls[callUUID]?.number ?? "No Number"; // Workaround because Android doesn't display well displayName, se we have to switch ... if (isIOS) { - _callKeep.updateDisplay(callUUID, callerName: 'New Name', handle: number); + _callKeep.updateDisplay( + uuid: callUUID, callerName: 'New Name', handle: number); } else { - _callKeep.updateDisplay(callUUID, callerName: number, handle: 'New Name'); + _callKeep.updateDisplay( + uuid: callUUID, callerName: number, handle: 'New Name'); } logger.d('[updateDisplay: $number] $callUUID'); @@ -302,7 +303,7 @@ class MyAppState extends State { logger.d('Display incoming call now'); final bool hasPhoneAccount = await _callKeep.hasPhoneAccount(); if (!hasPhoneAccount) { - await _callKeep.hasDefaultPhoneAccount(context, { + await _callKeep.hasDefaultPhoneAccount({ 'alertTitle': 'Permissions required', 'alertDescription': 'This application needs to access your phone accounts', @@ -318,8 +319,8 @@ class MyAppState extends State { } logger.d('[displayIncomingCall] $callUUID number: $number'); - _callKeep.displayIncomingCall(callUUID, number, - handleType: 'number', hasVideo: false); + _callKeep.displayIncomingCall( + uuid: callUUID, handle: number, handleType: 'number', hasVideo: false); } void didDisplayIncomingCall(CallKeepDidDisplayIncomingCall event) { @@ -347,29 +348,51 @@ class MyAppState extends State { _callKeep.on(didPerformDTMFAction); _callKeep.on(didReceiveStartCallAction); _callKeep.on(didToggleHoldCallAction); - _callKeep.on(didPerformSetMutedCallAction); + _callKeep + .on(didPerformSetMutedCallAction); _callKeep.on(endCall); _callKeep.on(onPushKitToken); - _callKeep.setup(context, { - 'ios': { - 'appName': 'CallKeepDemo', - }, - 'android': { - 'alertTitle': 'Permissions required', - 'alertDescription': - 'This application needs to access your phone accounts', - 'cancelButton': 'Cancel', - 'okButton': 'ok', - 'foregroundService': { - 'channelId': 'com.company.my', - 'channelName': 'Foreground service for my app', - 'notificationId': 5005, - 'notificationTitle': 'My app is running on background', - 'notificationIcon': 'Path to the resource icon of the notification', + _callKeep.setup( + showAlertDialog: () => showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Permissions Required'), + content: const Text( + 'This application needs to access your phone accounts'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(false), + ), + TextButton( + child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ); + }, + ).then((value) => value ?? false), + options: { + 'ios': { + 'appName': 'CallKeepDemo', + }, + 'android': { + 'additionalPermissions': [ + 'android.permission.CALL_PHONE', + 'android.permission.READ_PHONE_NUMBERS' + ], + 'foregroundService': { + 'channelId': 'com.example.call-kit-test', + 'channelName': 'callKitTest', + 'notificationTitle': 'My app is running on background', + 'notificationIcon': 'Path to the resource icon of the notification', + }, }, }, - }); + ); if (Platform.isIOS) iOSPermission(); @@ -394,8 +417,8 @@ class MyAppState extends State { calls[callUUID] = Call(callerId); }); _callKeep.displayIncomingCall( - callUUID, - callerId, + uuid: callUUID, + handle: callerId, callerName: callerName, hasVideo: hasVideo, ); diff --git a/ios/Classes/CallKeep.m b/ios/Classes/CallKeep.m index 900cc307..721c12d3 100644 --- a/ios/Classes/CallKeep.m +++ b/ios/Classes/CallKeep.m @@ -212,7 +212,6 @@ - (void)pushRegistry:(PKPushRegistry *)registry didUpdatePushCredentials:(PKPush - (NSString *)createUUID { CFUUIDRef uuidObject = CFUUIDCreate(kCFAllocatorDefault); NSString *uuidStr = (NSString *)CFBridgingRelease(CFUUIDCreateString(kCFAllocatorDefault, uuidObject)); - CFUUIDBytes bytes = CFUUIDGetUUIDBytes(uuidObject); CFRelease(uuidObject); return [uuidStr lowercaseString]; } diff --git a/lib/src/api.dart b/lib/src/api.dart index bc2c055f..50655a67 100644 --- a/lib/src/api.dart +++ b/lib/src/api.dart @@ -1,15 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart' - show - AlertDialog, - BuildContext, - Navigator, - Text, - TextButton, - Widget, - showDialog; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; @@ -30,23 +21,25 @@ class FlutterCallkeep extends EventManager { static final FlutterCallkeep _instance = FlutterCallkeep._internal(); static const MethodChannel _channel = MethodChannel('FlutterCallKeep.Method'); static const MethodChannel _event = MethodChannel('FlutterCallKeep.Event'); - BuildContext? _context; + Future Function()? _showAlertDialog; + + @override Logger logger = Logger(); - Future setup( - BuildContext? context, - Map options, { + Future setup({ + Future Function()? showAlertDialog, + required Map options, bool backgroundMode = false, }) async { - _context = context; + _showAlertDialog = showAlertDialog; if (!isIOS) { await _setupAndroid( - options['android'], - backgroundMode, + options: options['android'], + backgroundMode: backgroundMode, ); return; } - await _setupIOS(options['ios']); + await _setupIOS(options: options['ios']); } Future registerPhoneAccount() async { @@ -65,10 +58,8 @@ class FlutterCallkeep extends EventManager { } Future hasDefaultPhoneAccount( - BuildContext? context, Map options, ) async { - _context = context; if (!isIOS) { return await _hasDefaultPhoneAccount(options); } @@ -87,7 +78,7 @@ class FlutterCallkeep extends EventManager { Future _hasDefaultPhoneAccount(Map options) async { final hasDefault = await _checkDefaultPhoneAccount(); if (hasDefault == true) { - final shouldOpenAccounts = await _alert(options); + final shouldOpenAccounts = await _alert(); if (shouldOpenAccounts) { await _openPhoneAccounts(); return true; @@ -105,9 +96,9 @@ class FlutterCallkeep extends EventManager { return result ?? false; } - Future displayIncomingCall( - String uuid, - String handle, { + Future displayIncomingCall({ + required String uuid, + required String handle, String callerName = '', String handleType = 'number', bool hasVideo = false, @@ -130,10 +121,10 @@ class FlutterCallkeep extends EventManager { ); } - Future startCall( - String uuid, - String handle, - String callerName, { + Future startCall({ + required String uuid, + required String handle, + required String callerName, String handleType = 'number', bool hasVideo = false, Map additionalData = const {}, @@ -171,9 +162,9 @@ class FlutterCallkeep extends EventManager { } } - Future reportEndCallWithUUID( - String uuid, - int reason, { + Future reportEndCallWithUUID({ + required String uuid, + required int reason, bool notify = true, }) async { return await _channel.invokeMethod( @@ -254,11 +245,12 @@ class FlutterCallkeep extends EventManager { return false; } - Future setMutedCall(String uuid, bool shouldMute) async => + Future setMutedCall( + {required String uuid, required bool shouldMute}) async => await _channel.invokeMethod( 'setMutedCall', {'uuid': uuid, 'muted': shouldMute}); - Future sendDTMF(String uuid, String key) async => + Future sendDTMF({required String uuid, required String key}) async => await _channel.invokeMethod( 'sendDTMF', {'uuid': uuid, 'key': key}); @@ -288,8 +280,8 @@ class FlutterCallkeep extends EventManager { 'setCurrentCallActive', {'uuid': callUUID}); } - Future updateDisplay( - String uuid, { + Future updateDisplay({ + required String uuid, required String callerName, required String handle, }) async => @@ -299,7 +291,8 @@ class FlutterCallkeep extends EventManager { 'handle': handle }); - Future setOnHold(String uuid, bool shouldHold) async => + Future setOnHold( + {required String uuid, required bool shouldHold}) async => await _channel.invokeMethod( 'setOnHold', {'uuid': uuid, 'hold': shouldHold}); @@ -313,10 +306,10 @@ class FlutterCallkeep extends EventManager { } // @deprecated - Future reportUpdatedCall( - String uuid, - String callerName, - ) async { + Future reportUpdatedCall({ + required String uuid, + required String callerName, + }) async { logger.d( 'CallKeep.reportUpdatedCall is deprecated, use CallKeep.updateDisplay instead'); @@ -342,7 +335,7 @@ class FlutterCallkeep extends EventManager { return false; } - Future _setupIOS(Map options) async { + Future _setupIOS({required Map options}) async { if (options['appName'] == null) { throw Exception('CallKeep.setup: option "appName" is required'); } @@ -354,10 +347,10 @@ class FlutterCallkeep extends EventManager { .invokeMethod('setup', {'options': options}); } - Future _setupAndroid( - Map options, - bool backgroundMode, - ) async { + Future _setupAndroid({ + required Map options, + required bool backgroundMode, + }) async { await _channel.invokeMethod('setup', {'options': options}); if (backgroundMode) { @@ -373,7 +366,7 @@ class FlutterCallkeep extends EventManager { final hasPhoneAccount = await _hasPhoneAccount(); if (hasPhoneAccount != false) return true; - final shouldOpenAccounts = await _alert(options); + final shouldOpenAccounts = await _alert(); if (shouldOpenAccounts) { await _openPhoneAccounts(); return true; @@ -409,52 +402,17 @@ class FlutterCallkeep extends EventManager { return resp ?? false; } - Future _alert(Map options) async { - if (_context == null) return false; - var resp = await _showAlertDialog( - _context!, - options['alertTitle'] as String, - options['alertDescription'] as String, - options['cancelButton'] as String, - options['okButton'] as String); - if (resp != null) { - return resp; + Future _alert() async { + if (_showAlertDialog == null) { + logger.w('No alert dialog function provided. Defaulting to false.'); + return false; } - return false; - } - - Future _showAlertDialog( - BuildContext context, - String? alertTitle, - String? alertDescription, - String? cancelButton, - String? okButton, - ) async { - return await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(alertTitle ?? 'Permissions required'), - content: Text(alertDescription ?? - 'This application needs to access your phone accounts'), - actions: [ - TextButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: Text(cancelButton ?? 'Cancel'), - ), - TextButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: Text(okButton ?? 'ok'), - ), - ], - ), - ); + return await _showAlertDialog!(); } - Future setForegroundServiceSettings( - Map settings, - ) async { + Future setForegroundServiceSettings({ + required Map settings, + }) async { if (isIOS) { return; } diff --git a/lib/src/event.dart b/lib/src/event.dart index e4da0556..fd2ca826 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -77,14 +77,11 @@ class EventManager { }); } - void remove(T eventType, ValueChanged listener) { - final targets = listeners[eventType.runtimeType]; - if (targets == null) { - return; - } - // logger.warn("removing $eventType on $listener"); + void remove(ValueChanged listener) { + final targets = listeners[T]; + if (targets == null) return; if (!targets.remove(listener)) { - logger.d('Failed to remove any listeners for EventType $eventType'); + logger.d('Failed to remove any listeners for EventType $T'); } }