I'm using ConnectyCube Flutter to implement video call functionality in my Flutter app.
The call notifications work fine on Android, but on iOS, I don't receive any notification when a call is initiated from another device.
What I Have Done So Far:
- Configured Push Notifications:
- Set up Firebase Cloud Messaging (FCM) for push notifications.
- Uploaded the APNs certificate to ConnectyCube.
- Checked iOS Permissions: Enabled Push Notifications capability in Xcode. Ensured NSUserNotificationAlert is added in the Info.plist.
Tested with Background & Terminated State: On Android, the notification is received properly. On iOS, no notification appears in both foreground and background states. Debugging Logs: No errors related to push notifications appear in the logs. Verified that FCM token is correctly generated on iOS.
below is my AndroidManifest file
xmlns:tools=";
package="com.sambuq.care_first">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<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.REQUEST_INSTALL_PACKAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- End FlutterDownloader customization -->
<!-- Provide required visibility configuration for API level 30 and above -->
<queries>
<!-- If your app checks for SMS support -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="sms" />
</intent>
<!-- If your app checks for call support -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="360CareFirst"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<provider
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
android:authorities="${applicationId}.flutter_downloader.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
<!-- ... (other configurations) -->
<!-- Begin FlutterDownloader customization -->
<!-- disable default Initializer -->
<!-- Remove the following <provider> element -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- declare customized Initializer -->
<!-- Remove the following <provider> element -->
<provider
android:name="vn.hunghd.flutterdownloader.FlutterDownloaderInitializer"
android:authorities="${applicationId}.flutter-downloader-init"
android:exported="false">
<meta-data
android:name="vn.hunghd.flutterdownloader.MAX_CONCURRENT_TASKS"
android:value="1" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="FLUTTER_NOTIFICATION_CLICK"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
**call manager class **
import 'package:care_first/bloc/auth_bloc/bloc/auth_bloc.dart';
import 'package:care_first/routing/route_names.dart';
import 'package:care_first/screens/settings/my_appointments/doctor_appointment/models/my_appointment.dart';
import 'package:care_first/shared/helper/helper_methods.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:universal_io/io.dart';
import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart';
import 'package:connectycube_sdk/connectycube_sdk.dart';
import 'call_kit_manager.dart';
import '../utils/consts.dart';
class CallManager {
static String TAG = "CallManager";
static CallManager get instance => _getInstance();
static CallManager? _instance;
static CallManager _getInstance() {
return _instance ??= CallManager._internal();
}
factory CallManager() => _getInstance();
CallManager._internal();
P2PClient? _callClient;
P2PSession? _currentCall;
BuildContext? context;
MediaStream? localMediaStream;
Map<int, MediaStream> remoteStreams = {};
GlobalKey<NavigatorState>? navigatorKey;
String? selfCubeId;
late String resourceId;
SystemMessagesManager? systemMessagesManager =
CubeChatConnection.instance.systemMessagesManager;
CubeMessage systemMessage = CubeMessage();
final player = AudioPlayer();
Function(bool, String)? onMicMuted;
init(
BuildContext context,
GlobalKey<NavigatorState> navigatorKey,
String selfCubeId,
String resourceId,
) {
this.context = context;
this.navigatorKey = navigatorKey;
this.selfCubeId = selfCubeId;
this.resourceId = resourceId;
_initCustomMediaConfigs();
if (CubeChatConnection.instance.isAuthenticated()) {
_initCalls(context);
} else {
_initChatConnectionStateListener(context);
}
_initCallKit();
}
destroy() {
_callClient?.destroy();
_callClient = null;
}
void _initCustomMediaConfigs() {
RTCMediaConfig mediaConfig = RTCMediaConfig.instance;
mediaConfig.minHeight = 340;
mediaConfig.minWidth = 480;
mediaConfig.minFrameRate = 25;
RTCConfig.instance.statsReportsInterval = 200;
}
void _initCalls(BuildContext context) {
if (_callClient == null) {
_callClient = P2PClient.instance;
_callClient!.init();
}
// _callClient is P2PClient
_callClient!.onReceiveNewSession = (callSession) async {
print("${'*' * 10} RECEIVED NEW CALL SESSION ${'*' * 10}");
// if (_currentCall != null &&
// _currentCall!.sessionId != callSession.sessionId &&
// _currentCall!.opponentsIds.first.toString() != selfCubeId) {
// callSession.reject();
// return;
// }
if (navigatorKey!.currentContext!.read<AuthBloc>().user.persona ==
"patient") {
await player
.setReleaseMode(ReleaseMode.loop)
.then(
(value) => player.setSource(AssetSource('audio/ringtone.mp3')))
.then((value) => player.resume());
}
_currentCall = callSession;
var callState = await _getCallState(_currentCall!.sessionId);
await Future.delayed(Duration(seconds: 1));
if (callState == CallState.REJECTED) {
reject(_currentCall!.sessionId, false);
} else if (callState == CallState.ACCEPTED) {
acceptCall(_currentCall!.sessionId,
_currentCall?.cubeSdp.userInfo ?? {}, false);
} else if (callState == CallState.UNKNOWN ||
callState == CallState.PENDING) {
if (callState == CallState.UNKNOWN &&
(Platform.isIOS || Platform.isAndroid)) {
await ConnectycubeFlutterCallKit.setCallState(
sessionId: _currentCall!.sessionId, callState: CallState.PENDING);
}
if (_currentCall!.cubeSdp.callerId.toString() != selfCubeId) {
navigatorKey!.currentContext!.read<AuthBloc>().p2pSession =
callSession;
// await Future.delayed(Duration(seconds: 1));
// await ConnectycubeFlutterCallKit.showCallNotification(CallEvent(
// sessionId: _currentCall!.sessionId,
// callType: CallType.VIDEO_CALL,
// callerId: _currentCall!.cubeSdp.callerId,
// callerName: "YASH",
// opponentsIds: _currentCall!.opponentsIds,
// userInfo: _currentCall?.cubeSdp.userInfo ?? {},
// ));
print("NOTIFICATION NOT CALLED");
_showIncomingCallScreen(_currentCall!, navigatorKey!.currentContext!);
}
}
_currentCall?.onLocalStreamReceived = (localStream) {
localMediaStream = localStream;
};
_currentCall?.onRemoteStreamReceived = (session, userId, stream) {
remoteStreams[userId] = stream;
};
_currentCall?.onRemoteStreamRemoved = (session, userId, stream) {
remoteStreams.remove(userId);
};
_currentCall?.onReceiveHungUpFromUser =
(session, userId, [userInfo]) async {
print("CALL_MANAGER: onReceiveHungUpFromUser");
/* systemMessage.recipientId = int.tryParse(selfCubeId!);
systemMessage.properties["resourceId"] = resourceId;
systemMessage.properties["action"] = "CALL_HUNGUP";
print("-------------BROADCASTING CALL HUNGUP EVENT-----------");
systemMessagesManager?.sendSystemMessage(systemMessage); */
broadcastSystemMessage("CALL_HUNGUP");
await player.release();
if (GoRouter.of(navigatorKey!.currentContext!)!.location ==
RoutesName.incomingVideoCall) {
GoRouter.of(navigatorKey!.currentContext!).pop();
}
};
};
_callClient!.onSessionClosed = (callSession) async {
if (_currentCall != null &&
_currentCall!.sessionId == callSession.sessionId) {
_currentCall = null;
localMediaStream?.getTracks().forEach((track) async {
await track.stop();
});
await localMediaStream?.dispose();
localMediaStream = null;
remoteStreams.forEach((key, value) async {
await value.dispose();
});
remoteStreams.clear();
CallKitManager.instance.processCallFinished(callSession.sessionId);
if (GoRouter.of(navigatorKey!.currentContext!)!.location ==
RoutesName.incomingVideoCall) {
GoRouter.of(navigatorKey!.currentContext!).pop();
}
}
};
}
void startNewCall(BuildContext context, int callType, int patientCubeId,
MyAppointment appointment, String? businessId, String? moduleId) async {
this.context = context;
Set<int> opponents = {patientCubeId};
if (opponents.isEmpty) return;
if (!kIsWeb) {
if (Platform.isIOS) {
Helper.setAppleAudioIOMode(AppleAudioIOMode.localAndRemote,
preferSpeakerOutput: true);
}
}
P2PSession callSession =
_callClient!.createCallSession(callType, opponents);
_currentCall = callSession;
// storing it in authbloc
context.read<AuthBloc>().p2pSession = callSession;
context.read<AuthBloc>().resourceId = resourceId;
context.read<AuthBloc>().selfCubeId = selfCubeId;
String callerName;
if (appointment.doctorMap?["firstname"] != null) {
callerName =
"${appointment.doctorMap?["firstname"]} ${appointment.doctorMap?["lastname"]}";
} else {
callerName = "";
}
_sendStartCallSignalForOffliners(_currentCall!, callerName);
GoRouter.of(context)
.pushNamed(RoutesName.videoCallDoctorRoute, pathParameters: {
'businessId': encrypt(text: businessId ?? '', context: context),
}, queryParameters: {
'moduleId': moduleId,
}, extra: {
// 'callSession': callSession,
'appointment': appointment,
});
}
void _showIncomingCallScreen(P2PSession callSession, BuildContext context) {
if (navigatorKey!.currentContext != null) {
GoRouter.of(navigatorKey!.currentContext!).pushNamed(
RoutesName.incomingVideoCall,
extra: Map<String, String>.from(
{"resourceId": resourceId, "selfCubeId": selfCubeId}));
}
}
void acceptCall(
String sessionId,
Map<String, String> userInfo,
bool fromCallkit,
) async {
await player.release();
log('acceptCall, from callKit: $fromCallkit', TAG);
//ConnectycubeFlutterCallKit.setOnLockScreenVisibility(isVisible: true);
if (_currentCall != null) {
if (context != null) {
// if (AppLifecycleState.resumed !=
// WidgetsBinding.instance.lifecycleState) {
await _currentCall?.acceptCall();
// }
MyAppointment appointment = MyAppointment(
id: userInfo['appointment_id'], doctor: userInfo['doctor_id']);
log(userInfo.toString(), "User Info from AcceptCall");
if (!fromCallkit) {
await ConnectycubeFlutterCallKit.reportCallAccepted(
sessionId: sessionId);
}
broadcastSystemMessage("CALL_ACCEPTED");
navigatorKey!.currentContext!.read<AuthBloc>().p2pSession =
_currentCall;
GoRouter.of(navigatorKey!.currentContext!).pushReplacementNamed(
RoutesName.videoCallPatientRoute,
extra: Map<String, Object>.from({
// 'currentCall': _currentCall!,
'appointment': appointment,
}),
);
/* systemMessage.recipientId = int.tryParse(selfCubeId!);
systemMessage.properties["resourceId"] = resourceId;
systemMessage.properties["action"] = "CALL_ACCEPTED";
print("-------------BROADCASTING ACCEPT CALL EVENT-----------");
systemMessagesManager?.sendSystemMessage(systemMessage); */
}
if (!kIsWeb) {
if (Platform.isIOS) {
await Helper.setAppleAudioIOMode(AppleAudioIOMode.localAndRemote,
preferSpeakerOutput: true);
}
}
}
}
void reject(String sessionId, bool fromCallkit) {
if (_currentCall != null) {
player.release();
broadcastSystemMessage("CALL_REJECTED");
if (fromCallkit) {
ConnectycubeFlutterCallKit.setOnLockScreenVisibility(isVisible: false);
} else {
CallKitManager.instance.processCallFinished(_currentCall!.sessionId);
}
_currentCall!.reject();
_sendEndCallSignalForOffliners(_currentCall, null);
}
}
void hungUp() {
if (_currentCall != null) {
player.release();
CallKitManager.instance.processCallFinished(_currentCall!.sessionId);
_currentCall!.hungUp();
_sendEndCallSignalForOffliners(_currentCall, null);
}
}
CreateEventParams _getCallEventParameters(
P2PSession currentCall, String? callerName) {
/* String? callerName = users
.where((cubeUser) => cubeUser.id == currentCall.callerId)
.first
.fullName; */
CreateEventParams params = CreateEventParams();
params.parameters = {
'message':
"Incoming ${currentCall.callType == CallType.VIDEO_CALL ? "Video" : "Audio"} call",
PARAM_CALL_TYPE: currentCall.callType,
PARAM_SESSION_ID: currentCall.sessionId,
PARAM_CALLER_ID: currentCall.callerId,
PARAM_CALL_OPPONENTS: currentCall.opponentsIds.join(','),
PARAM_CALLER_NAME: callerName ?? "Doctor",
};
params.notificationType = NotificationType.PUSH;
params.environment = CubeEnvironment.DEVELOPMENT;
//kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
params.usersIds = currentCall.opponentsIds.toList();
return params;
}
Future<void> _sendStartCallSignalForOffliners(
P2PSession currentCall, String callerName) async {
CreateEventParams params = _getCallEventParameters(currentCall, callerName);
params.parameters[PARAM_SIGNAL_TYPE] = SIGNAL_TYPE_START_CALL;
params.parameters[PARAM_IOS_VOIP] = 1;
params.parameters[PARAM_EXPIRATION] = 0;
params.parameters['ios_push_type'] = 'background';
await createEvent(params.getEventForRequest()).then((cubeEvent) {
log("Event for offliners created: $cubeEvent");
}).catchError((error) {
log("ERROR occurs during create event");
});
}
void _sendEndCallSignalForOffliners(
P2PSession? currentCall, String? callerName) {
if (currentCall == null) return;
CubeUser? currentUser = CubeChatConnection.instance.currentUser;
if (currentUser == null || currentUser.id != currentCall.callerId) return;
CreateEventParams params = _getCallEventParameters(currentCall, callerName);
params.parameters[PARAM_SIGNAL_TYPE] = SIGNAL_TYPE_END_CALL;
createEvent(params.getEventForRequest()).then((cubeEvent) {
log("Event for offliners created");
}).catchError((error) {
log("ERROR occurs during create event");
});
}
void _initCallKit() {
CallKitManager.instance.init(
onCallAccepted: (uuid) {
acceptCall(uuid, _currentCall?.cubeSdp.userInfo ?? {}, true);
},
onCallEnded: (uuid) {
reject(uuid, true);
},
onMuteCall: (mute, uuid) {
onMicMuted?.call(mute, uuid);
},
);
}
void _initChatConnectionStateListener(BuildContext context) {
CubeChatConnection.instance.connectionStateStream.listen((state) {
if (CubeChatConnectionState.Ready == state) {
_initCalls(context);
}
});
}
Future<String> _getCallState(String sessionId) async {
if (Platform.isAndroid || Platform.isIOS) {
var callState =
await ConnectycubeFlutterCallKit.getCallState(sessionId: sessionId);
log("CONECTICUBE CALL STATE: $callState");
return callState;
} else {
return Future.value(CallState.UNKNOWN);
}
}
void muteCall(String sessionId, bool mute) {
CallKitManager.instance.muteCall(sessionId, mute);
}
Future<bool> _onBackPressed(BuildContext context) {
return Future.value(false);
}
void broadcastSystemMessage(action) {
systemMessage.recipientId = int.tryParse(selfCubeId!);
systemMessage.properties["resourceId"] = resourceId;
systemMessage.properties["action"] = action;
print("BROADCASTING: $action EVENT");
systemMessagesManager?.sendSystemMessage(systemMessage);
}
}
FCM SETUP CLASS
import 'package:connectycube_sdk/connectycube_sdk.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:universal_io/io.dart';
import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart';
class FcmSetup {
static FcmSetup? _instance;
FcmSetup._internal();
factory FcmSetup() {
return _instance ??= FcmSetup._internal();
}
FirebaseMessaging? firebaseMessaging;
Future<void> init({
required Function(RemoteMessage remoteMessage) onMessage,
}) async {
firebaseMessaging = FirebaseMessaging.instance;
log("INT STARTED");
await firebaseMessaging!.requestPermission(
alert: false,
announcement: false,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
String? token;
if (Platform.isAndroid || kIsWeb) {
token = await firebaseMessaging!.getToken();
} else if (Platform.isIOS || Platform.isMacOS) {
token = await firebaseMessaging!.getAPNSToken();
log("APNS TOKEN $token");
}
if (token != null) {
subscribe(token);
}
if (Platform.isIOS || Platform.isMacOS) {
String? voipToken = await ConnectycubeFlutterCallKit.getToken();
log("VOIP TOKEN $voipToken");
if (voipToken != null) {
subscribeVoIP(voipToken);
}
}
firebaseMessaging!.onTokenRefresh.listen((newToken) async {
subscribe(newToken);
});
// FirebaseMessaging.onMessage.listen(onMessage);
// FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
}
subscribe(String token) async {
log('[subscribe] token: $token');
bool isProduction = bool.fromEnvironment('dart.vm.product');
CreateSubscriptionParameters parameters = CreateSubscriptionParameters();
parameters.environment =
isProduction ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
if (Platform.isAndroid) {
parameters.channel = NotificationsChannels.GCM;
parameters.platform = CubePlatform.ANDROID;
parameters.bundleIdentifier = "com.360carefirst.app";
} else if (Platform.isIOS) {
parameters.channel = NotificationsChannels.APNS;
parameters.platform = CubePlatform.IOS;
parameters.bundleIdentifier = Platform.isIOS
? "com.360carefirst.app"
: "com.connectycube.flutter.chatSample.macOS";
}
String deviceId = await getDeviceId();
parameters.udid = deviceId;
parameters.pushToken = token;
createSubscription(parameters.getRequestParameters())
.then((cubeSubscription) {
getSubscriptions().then((subscriptions) {
log("Subscriptions: ${subscriptions.toString()}");
});
log("SUBSCRIPTION CREATED");
}).catchError((error) {
log("SUBSCRIPTION ERROR ${error}");
});
}
Future<String> getDeviceId() async {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
return androidInfo.id; // Unique ID for Android
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
return iosInfo.identifierForVendor!;
}
return '';
}
void sendPushNotification(List<int> ids, String message, String title) {
bool isProduction = bool.fromEnvironment('dart.vm.product');
CreateEventParams params = CreateEventParams();
params.parameters = {
'message': message, // Required
'title': title, // Required
'ios_voip': 1, // Required for iOS VoIP push
'push_badge': 1, // Updates app badge count
'push_sound': 'default', // Plays default notification sound
'custom_data': {
'param1': 'value1', // Custom parameters (optional)
'param2': 'value2',
},
'aps': {
'alert': {'title': title, 'body': message},
'sound': 'default',
'content-available': 1 // Required for silent VoIP pushes
}
};
params.notificationType = NotificationType.PUSH;
params.environment =
isProduction ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
params.usersIds = ids;
createEvent(params.getEventForRequest()).then((cubeEvent) {
log("SENT TO IDS ${ids.toString()}");
}).catchError((error) {
log("ERROR WHILE SEDING TO IDS");
});
}
Future<void> unsubscribe() async {
try {
List<CubeSubscription> subscriptionsList = await getSubscriptions();
log("SUBSCRIBED USER LIST ${subscriptionsList.length}");
for (CubeSubscription subscription in subscriptionsList) {
log("SUBSCRIPTION ID ${subscription.id}");
await deleteSubscription(subscription.id!);
}
log("UNSUBSCRIBED SUC");
} on Exception catch (e) {
log("UNSUBSCRIBED ERROR ${e}");
}
}
subscribeVoIP(String token) async {
log('[subscribeVoIP] token: $token');
CreateSubscriptionParameters parameters = CreateSubscriptionParameters();
parameters.pushToken = token;
if (Platform.isIOS) {
parameters.channel = NotificationsChannels.APNS_VOIP;
parameters.platform = CubePlatform.IOS;
}
String deviceId = await getDeviceId();
parameters.udid = deviceId;
parameters.environment =
kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
var packageInfo = await PackageInfo.fromPlatform();
parameters.bundleIdentifier = packageInfo.packageName;
createSubscription(parameters.getRequestParameters())
.then((cubeSubscriptions) {
log('[subscribeVoIP] subscription SUCCESS');
}).catchError((error) {
log('[subscribeVoIP] subscription ERROR: $error');
});
}
}
I'm using ConnectyCube Flutter to implement video call functionality in my Flutter app.
The call notifications work fine on Android, but on iOS, I don't receive any notification when a call is initiated from another device.
What I Have Done So Far:
- Configured Push Notifications:
- Set up Firebase Cloud Messaging (FCM) for push notifications.
- Uploaded the APNs certificate to ConnectyCube.
- Checked iOS Permissions: Enabled Push Notifications capability in Xcode. Ensured NSUserNotificationAlert is added in the Info.plist.
Tested with Background & Terminated State: On Android, the notification is received properly. On iOS, no notification appears in both foreground and background states. Debugging Logs: No errors related to push notifications appear in the logs. Verified that FCM token is correctly generated on iOS.
below is my AndroidManifest file
xmlns:tools="http://schemas.android/tools"
package="com.sambuq.care_first">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<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.REQUEST_INSTALL_PACKAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- End FlutterDownloader customization -->
<!-- Provide required visibility configuration for API level 30 and above -->
<queries>
<!-- If your app checks for SMS support -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="sms" />
</intent>
<!-- If your app checks for call support -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="360CareFirst"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<provider
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
android:authorities="${applicationId}.flutter_downloader.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
<!-- ... (other configurations) -->
<!-- Begin FlutterDownloader customization -->
<!-- disable default Initializer -->
<!-- Remove the following <provider> element -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- declare customized Initializer -->
<!-- Remove the following <provider> element -->
<provider
android:name="vn.hunghd.flutterdownloader.FlutterDownloaderInitializer"
android:authorities="${applicationId}.flutter-downloader-init"
android:exported="false">
<meta-data
android:name="vn.hunghd.flutterdownloader.MAX_CONCURRENT_TASKS"
android:value="1" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="FLUTTER_NOTIFICATION_CLICK"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
**call manager class **
import 'package:care_first/bloc/auth_bloc/bloc/auth_bloc.dart';
import 'package:care_first/routing/route_names.dart';
import 'package:care_first/screens/settings/my_appointments/doctor_appointment/models/my_appointment.dart';
import 'package:care_first/shared/helper/helper_methods.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:universal_io/io.dart';
import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart';
import 'package:connectycube_sdk/connectycube_sdk.dart';
import 'call_kit_manager.dart';
import '../utils/consts.dart';
class CallManager {
static String TAG = "CallManager";
static CallManager get instance => _getInstance();
static CallManager? _instance;
static CallManager _getInstance() {
return _instance ??= CallManager._internal();
}
factory CallManager() => _getInstance();
CallManager._internal();
P2PClient? _callClient;
P2PSession? _currentCall;
BuildContext? context;
MediaStream? localMediaStream;
Map<int, MediaStream> remoteStreams = {};
GlobalKey<NavigatorState>? navigatorKey;
String? selfCubeId;
late String resourceId;
SystemMessagesManager? systemMessagesManager =
CubeChatConnection.instance.systemMessagesManager;
CubeMessage systemMessage = CubeMessage();
final player = AudioPlayer();
Function(bool, String)? onMicMuted;
init(
BuildContext context,
GlobalKey<NavigatorState> navigatorKey,
String selfCubeId,
String resourceId,
) {
this.context = context;
this.navigatorKey = navigatorKey;
this.selfCubeId = selfCubeId;
this.resourceId = resourceId;
_initCustomMediaConfigs();
if (CubeChatConnection.instance.isAuthenticated()) {
_initCalls(context);
} else {
_initChatConnectionStateListener(context);
}
_initCallKit();
}
destroy() {
_callClient?.destroy();
_callClient = null;
}
void _initCustomMediaConfigs() {
RTCMediaConfig mediaConfig = RTCMediaConfig.instance;
mediaConfig.minHeight = 340;
mediaConfig.minWidth = 480;
mediaConfig.minFrameRate = 25;
RTCConfig.instance.statsReportsInterval = 200;
}
void _initCalls(BuildContext context) {
if (_callClient == null) {
_callClient = P2PClient.instance;
_callClient!.init();
}
// _callClient is P2PClient
_callClient!.onReceiveNewSession = (callSession) async {
print("${'*' * 10} RECEIVED NEW CALL SESSION ${'*' * 10}");
// if (_currentCall != null &&
// _currentCall!.sessionId != callSession.sessionId &&
// _currentCall!.opponentsIds.first.toString() != selfCubeId) {
// callSession.reject();
// return;
// }
if (navigatorKey!.currentContext!.read<AuthBloc>().user.persona ==
"patient") {
await player
.setReleaseMode(ReleaseMode.loop)
.then(
(value) => player.setSource(AssetSource('audio/ringtone.mp3')))
.then((value) => player.resume());
}
_currentCall = callSession;
var callState = await _getCallState(_currentCall!.sessionId);
await Future.delayed(Duration(seconds: 1));
if (callState == CallState.REJECTED) {
reject(_currentCall!.sessionId, false);
} else if (callState == CallState.ACCEPTED) {
acceptCall(_currentCall!.sessionId,
_currentCall?.cubeSdp.userInfo ?? {}, false);
} else if (callState == CallState.UNKNOWN ||
callState == CallState.PENDING) {
if (callState == CallState.UNKNOWN &&
(Platform.isIOS || Platform.isAndroid)) {
await ConnectycubeFlutterCallKit.setCallState(
sessionId: _currentCall!.sessionId, callState: CallState.PENDING);
}
if (_currentCall!.cubeSdp.callerId.toString() != selfCubeId) {
navigatorKey!.currentContext!.read<AuthBloc>().p2pSession =
callSession;
// await Future.delayed(Duration(seconds: 1));
// await ConnectycubeFlutterCallKit.showCallNotification(CallEvent(
// sessionId: _currentCall!.sessionId,
// callType: CallType.VIDEO_CALL,
// callerId: _currentCall!.cubeSdp.callerId,
// callerName: "YASH",
// opponentsIds: _currentCall!.opponentsIds,
// userInfo: _currentCall?.cubeSdp.userInfo ?? {},
// ));
print("NOTIFICATION NOT CALLED");
_showIncomingCallScreen(_currentCall!, navigatorKey!.currentContext!);
}
}
_currentCall?.onLocalStreamReceived = (localStream) {
localMediaStream = localStream;
};
_currentCall?.onRemoteStreamReceived = (session, userId, stream) {
remoteStreams[userId] = stream;
};
_currentCall?.onRemoteStreamRemoved = (session, userId, stream) {
remoteStreams.remove(userId);
};
_currentCall?.onReceiveHungUpFromUser =
(session, userId, [userInfo]) async {
print("CALL_MANAGER: onReceiveHungUpFromUser");
/* systemMessage.recipientId = int.tryParse(selfCubeId!);
systemMessage.properties["resourceId"] = resourceId;
systemMessage.properties["action"] = "CALL_HUNGUP";
print("-------------BROADCASTING CALL HUNGUP EVENT-----------");
systemMessagesManager?.sendSystemMessage(systemMessage); */
broadcastSystemMessage("CALL_HUNGUP");
await player.release();
if (GoRouter.of(navigatorKey!.currentContext!)!.location ==
RoutesName.incomingVideoCall) {
GoRouter.of(navigatorKey!.currentContext!).pop();
}
};
};
_callClient!.onSessionClosed = (callSession) async {
if (_currentCall != null &&
_currentCall!.sessionId == callSession.sessionId) {
_currentCall = null;
localMediaStream?.getTracks().forEach((track) async {
await track.stop();
});
await localMediaStream?.dispose();
localMediaStream = null;
remoteStreams.forEach((key, value) async {
await value.dispose();
});
remoteStreams.clear();
CallKitManager.instance.processCallFinished(callSession.sessionId);
if (GoRouter.of(navigatorKey!.currentContext!)!.location ==
RoutesName.incomingVideoCall) {
GoRouter.of(navigatorKey!.currentContext!).pop();
}
}
};
}
void startNewCall(BuildContext context, int callType, int patientCubeId,
MyAppointment appointment, String? businessId, String? moduleId) async {
this.context = context;
Set<int> opponents = {patientCubeId};
if (opponents.isEmpty) return;
if (!kIsWeb) {
if (Platform.isIOS) {
Helper.setAppleAudioIOMode(AppleAudioIOMode.localAndRemote,
preferSpeakerOutput: true);
}
}
P2PSession callSession =
_callClient!.createCallSession(callType, opponents);
_currentCall = callSession;
// storing it in authbloc
context.read<AuthBloc>().p2pSession = callSession;
context.read<AuthBloc>().resourceId = resourceId;
context.read<AuthBloc>().selfCubeId = selfCubeId;
String callerName;
if (appointment.doctorMap?["firstname"] != null) {
callerName =
"${appointment.doctorMap?["firstname"]} ${appointment.doctorMap?["lastname"]}";
} else {
callerName = "";
}
_sendStartCallSignalForOffliners(_currentCall!, callerName);
GoRouter.of(context)
.pushNamed(RoutesName.videoCallDoctorRoute, pathParameters: {
'businessId': encrypt(text: businessId ?? '', context: context),
}, queryParameters: {
'moduleId': moduleId,
}, extra: {
// 'callSession': callSession,
'appointment': appointment,
});
}
void _showIncomingCallScreen(P2PSession callSession, BuildContext context) {
if (navigatorKey!.currentContext != null) {
GoRouter.of(navigatorKey!.currentContext!).pushNamed(
RoutesName.incomingVideoCall,
extra: Map<String, String>.from(
{"resourceId": resourceId, "selfCubeId": selfCubeId}));
}
}
void acceptCall(
String sessionId,
Map<String, String> userInfo,
bool fromCallkit,
) async {
await player.release();
log('acceptCall, from callKit: $fromCallkit', TAG);
//ConnectycubeFlutterCallKit.setOnLockScreenVisibility(isVisible: true);
if (_currentCall != null) {
if (context != null) {
// if (AppLifecycleState.resumed !=
// WidgetsBinding.instance.lifecycleState) {
await _currentCall?.acceptCall();
// }
MyAppointment appointment = MyAppointment(
id: userInfo['appointment_id'], doctor: userInfo['doctor_id']);
log(userInfo.toString(), "User Info from AcceptCall");
if (!fromCallkit) {
await ConnectycubeFlutterCallKit.reportCallAccepted(
sessionId: sessionId);
}
broadcastSystemMessage("CALL_ACCEPTED");
navigatorKey!.currentContext!.read<AuthBloc>().p2pSession =
_currentCall;
GoRouter.of(navigatorKey!.currentContext!).pushReplacementNamed(
RoutesName.videoCallPatientRoute,
extra: Map<String, Object>.from({
// 'currentCall': _currentCall!,
'appointment': appointment,
}),
);
/* systemMessage.recipientId = int.tryParse(selfCubeId!);
systemMessage.properties["resourceId"] = resourceId;
systemMessage.properties["action"] = "CALL_ACCEPTED";
print("-------------BROADCASTING ACCEPT CALL EVENT-----------");
systemMessagesManager?.sendSystemMessage(systemMessage); */
}
if (!kIsWeb) {
if (Platform.isIOS) {
await Helper.setAppleAudioIOMode(AppleAudioIOMode.localAndRemote,
preferSpeakerOutput: true);
}
}
}
}
void reject(String sessionId, bool fromCallkit) {
if (_currentCall != null) {
player.release();
broadcastSystemMessage("CALL_REJECTED");
if (fromCallkit) {
ConnectycubeFlutterCallKit.setOnLockScreenVisibility(isVisible: false);
} else {
CallKitManager.instance.processCallFinished(_currentCall!.sessionId);
}
_currentCall!.reject();
_sendEndCallSignalForOffliners(_currentCall, null);
}
}
void hungUp() {
if (_currentCall != null) {
player.release();
CallKitManager.instance.processCallFinished(_currentCall!.sessionId);
_currentCall!.hungUp();
_sendEndCallSignalForOffliners(_currentCall, null);
}
}
CreateEventParams _getCallEventParameters(
P2PSession currentCall, String? callerName) {
/* String? callerName = users
.where((cubeUser) => cubeUser.id == currentCall.callerId)
.first
.fullName; */
CreateEventParams params = CreateEventParams();
params.parameters = {
'message':
"Incoming ${currentCall.callType == CallType.VIDEO_CALL ? "Video" : "Audio"} call",
PARAM_CALL_TYPE: currentCall.callType,
PARAM_SESSION_ID: currentCall.sessionId,
PARAM_CALLER_ID: currentCall.callerId,
PARAM_CALL_OPPONENTS: currentCall.opponentsIds.join(','),
PARAM_CALLER_NAME: callerName ?? "Doctor",
};
params.notificationType = NotificationType.PUSH;
params.environment = CubeEnvironment.DEVELOPMENT;
//kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
params.usersIds = currentCall.opponentsIds.toList();
return params;
}
Future<void> _sendStartCallSignalForOffliners(
P2PSession currentCall, String callerName) async {
CreateEventParams params = _getCallEventParameters(currentCall, callerName);
params.parameters[PARAM_SIGNAL_TYPE] = SIGNAL_TYPE_START_CALL;
params.parameters[PARAM_IOS_VOIP] = 1;
params.parameters[PARAM_EXPIRATION] = 0;
params.parameters['ios_push_type'] = 'background';
await createEvent(params.getEventForRequest()).then((cubeEvent) {
log("Event for offliners created: $cubeEvent");
}).catchError((error) {
log("ERROR occurs during create event");
});
}
void _sendEndCallSignalForOffliners(
P2PSession? currentCall, String? callerName) {
if (currentCall == null) return;
CubeUser? currentUser = CubeChatConnection.instance.currentUser;
if (currentUser == null || currentUser.id != currentCall.callerId) return;
CreateEventParams params = _getCallEventParameters(currentCall, callerName);
params.parameters[PARAM_SIGNAL_TYPE] = SIGNAL_TYPE_END_CALL;
createEvent(params.getEventForRequest()).then((cubeEvent) {
log("Event for offliners created");
}).catchError((error) {
log("ERROR occurs during create event");
});
}
void _initCallKit() {
CallKitManager.instance.init(
onCallAccepted: (uuid) {
acceptCall(uuid, _currentCall?.cubeSdp.userInfo ?? {}, true);
},
onCallEnded: (uuid) {
reject(uuid, true);
},
onMuteCall: (mute, uuid) {
onMicMuted?.call(mute, uuid);
},
);
}
void _initChatConnectionStateListener(BuildContext context) {
CubeChatConnection.instance.connectionStateStream.listen((state) {
if (CubeChatConnectionState.Ready == state) {
_initCalls(context);
}
});
}
Future<String> _getCallState(String sessionId) async {
if (Platform.isAndroid || Platform.isIOS) {
var callState =
await ConnectycubeFlutterCallKit.getCallState(sessionId: sessionId);
log("CONECTICUBE CALL STATE: $callState");
return callState;
} else {
return Future.value(CallState.UNKNOWN);
}
}
void muteCall(String sessionId, bool mute) {
CallKitManager.instance.muteCall(sessionId, mute);
}
Future<bool> _onBackPressed(BuildContext context) {
return Future.value(false);
}
void broadcastSystemMessage(action) {
systemMessage.recipientId = int.tryParse(selfCubeId!);
systemMessage.properties["resourceId"] = resourceId;
systemMessage.properties["action"] = action;
print("BROADCASTING: $action EVENT");
systemMessagesManager?.sendSystemMessage(systemMessage);
}
}
FCM SETUP CLASS
import 'package:connectycube_sdk/connectycube_sdk.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:universal_io/io.dart';
import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart';
class FcmSetup {
static FcmSetup? _instance;
FcmSetup._internal();
factory FcmSetup() {
return _instance ??= FcmSetup._internal();
}
FirebaseMessaging? firebaseMessaging;
Future<void> init({
required Function(RemoteMessage remoteMessage) onMessage,
}) async {
firebaseMessaging = FirebaseMessaging.instance;
log("INT STARTED");
await firebaseMessaging!.requestPermission(
alert: false,
announcement: false,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
String? token;
if (Platform.isAndroid || kIsWeb) {
token = await firebaseMessaging!.getToken();
} else if (Platform.isIOS || Platform.isMacOS) {
token = await firebaseMessaging!.getAPNSToken();
log("APNS TOKEN $token");
}
if (token != null) {
subscribe(token);
}
if (Platform.isIOS || Platform.isMacOS) {
String? voipToken = await ConnectycubeFlutterCallKit.getToken();
log("VOIP TOKEN $voipToken");
if (voipToken != null) {
subscribeVoIP(voipToken);
}
}
firebaseMessaging!.onTokenRefresh.listen((newToken) async {
subscribe(newToken);
});
// FirebaseMessaging.onMessage.listen(onMessage);
// FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
}
subscribe(String token) async {
log('[subscribe] token: $token');
bool isProduction = bool.fromEnvironment('dart.vm.product');
CreateSubscriptionParameters parameters = CreateSubscriptionParameters();
parameters.environment =
isProduction ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
if (Platform.isAndroid) {
parameters.channel = NotificationsChannels.GCM;
parameters.platform = CubePlatform.ANDROID;
parameters.bundleIdentifier = "com.360carefirst.app";
} else if (Platform.isIOS) {
parameters.channel = NotificationsChannels.APNS;
parameters.platform = CubePlatform.IOS;
parameters.bundleIdentifier = Platform.isIOS
? "com.360carefirst.app"
: "com.connectycube.flutter.chatSample.macOS";
}
String deviceId = await getDeviceId();
parameters.udid = deviceId;
parameters.pushToken = token;
createSubscription(parameters.getRequestParameters())
.then((cubeSubscription) {
getSubscriptions().then((subscriptions) {
log("Subscriptions: ${subscriptions.toString()}");
});
log("SUBSCRIPTION CREATED");
}).catchError((error) {
log("SUBSCRIPTION ERROR ${error}");
});
}
Future<String> getDeviceId() async {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
return androidInfo.id; // Unique ID for Android
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
return iosInfo.identifierForVendor!;
}
return '';
}
void sendPushNotification(List<int> ids, String message, String title) {
bool isProduction = bool.fromEnvironment('dart.vm.product');
CreateEventParams params = CreateEventParams();
params.parameters = {
'message': message, // Required
'title': title, // Required
'ios_voip': 1, // Required for iOS VoIP push
'push_badge': 1, // Updates app badge count
'push_sound': 'default', // Plays default notification sound
'custom_data': {
'param1': 'value1', // Custom parameters (optional)
'param2': 'value2',
},
'aps': {
'alert': {'title': title, 'body': message},
'sound': 'default',
'content-available': 1 // Required for silent VoIP pushes
}
};
params.notificationType = NotificationType.PUSH;
params.environment =
isProduction ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
params.usersIds = ids;
createEvent(params.getEventForRequest()).then((cubeEvent) {
log("SENT TO IDS ${ids.toString()}");
}).catchError((error) {
log("ERROR WHILE SEDING TO IDS");
});
}
Future<void> unsubscribe() async {
try {
List<CubeSubscription> subscriptionsList = await getSubscriptions();
log("SUBSCRIBED USER LIST ${subscriptionsList.length}");
for (CubeSubscription subscription in subscriptionsList) {
log("SUBSCRIPTION ID ${subscription.id}");
await deleteSubscription(subscription.id!);
}
log("UNSUBSCRIBED SUC");
} on Exception catch (e) {
log("UNSUBSCRIBED ERROR ${e}");
}
}
subscribeVoIP(String token) async {
log('[subscribeVoIP] token: $token');
CreateSubscriptionParameters parameters = CreateSubscriptionParameters();
parameters.pushToken = token;
if (Platform.isIOS) {
parameters.channel = NotificationsChannels.APNS_VOIP;
parameters.platform = CubePlatform.IOS;
}
String deviceId = await getDeviceId();
parameters.udid = deviceId;
parameters.environment =
kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT;
var packageInfo = await PackageInfo.fromPlatform();
parameters.bundleIdentifier = packageInfo.packageName;
createSubscription(parameters.getRequestParameters())
.then((cubeSubscriptions) {
log('[subscribeVoIP] subscription SUCCESS');
}).catchError((error) {
log('[subscribeVoIP] subscription ERROR: $error');
});
}
}
Share
Improve this question
asked Mar 7 at 8:47
yash guptayash gupta
871 silver badge8 bronze badges
1 Answer
Reset to default 0At first need to check the guide how to configure Push Notifications. After all setup check with some of call samples. Also take a look at similar issue at github issue
But again, it's important to look at official guide and double check all needed configuration settings.
发布者:admin,转转请注明出处:http://www.yc00.com/questions/1744940999a4602320.html
评论列表(0条)