diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 2bccb48c..e4711acf 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -67,6 +67,7 @@
fetch
remote-notification
voip
+ audio
processing
UILaunchStoryboardName
diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart
index bf84f13e..5db8b611 100644
--- a/lib/controllers/notification/firebase_notification_manger.dart
+++ b/lib/controllers/notification/firebase_notification_manger.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
+import 'dart:math';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
@@ -14,7 +15,6 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:google_api_availability/google_api_availability.dart';
import 'package:huawei_push/huawei_push.dart' as h_push;
import 'package:test_sa/controllers/notification/notification_manger.dart';
-import 'package:test_sa/extensions/string_extensions.dart';
import 'package:test_sa/models/device/device_transfer.dart';
import 'package:test_sa/models/new_models/gas_refill_model.dart';
import 'package:test_sa/modules/cm_module/cm_detail_page.dart';
@@ -28,53 +28,71 @@ import 'package:test_sa/modules/tm_module/gas_refill/gas_refill_details.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import 'package:test_sa/views/widgets/loaders/no_data_found.dart';
+
@pragma('vm:entry-point')
Future firebaseMessagingBackgroundHandler(RemoteMessage message) async {
if (message.notification == null && message.data != null) {
+ // Killed app: main() does not run for an FCM background message, so the callkit background
+ // engine (registered in main) is not started and Accept/Decline taps would be dropped.
+ // Registering here starts it, so decline reaches `onBackgroundMessage` below.
+ if (Platform.isAndroid && message.data["type"] == "incoming_call") {
+ try {
+ await FlutterCallkitIncoming.onBackgroundMessage(onBackgroundMessage);
+ } catch (_) {}
+ }
handleDataMessage(message.data, true);
}
}
+/// Android: receives callkit events while no UI listener is active (app killed / not on LandPage).
+/// When the app is in the foreground, events go to `callingEvents` in land_page.dart instead.
@pragma('vm:entry-point')
-Future onBackgroundMessage(CallEvent event1) async {
- // This executes when a background/killed action happens
-
-
- FlutterCallkitIncoming.onEvent.listen((CallEvent? event) {
- switch (event!.eventName) {
- case CallEventConstants.actionCallAccept:
- // Handle logic when user hits Accept while app transitions to foreground
- // var extraData = event?.body['extra'];
- // Navigate to Call Screen
- print("onBackgroundMessage:actionCallAccept:${event}");
- break;
- case CallEventConstants.actionCallDecline:
- // Notify backend that user declined
- print("onBackgroundMessage:actionCallDecline:${event}");
- break;
- default:
- break;
- }
- });
-
- switch (event1.eventName) {
- case CallEventConstants.actionCallAccept:
- "actionCallAccept".showToast;
- break;
- case CallEventConstants.actionCallDecline:
- "actionCallDecline".showToast;
- break;
+Future onBackgroundMessage(CallEvent event) async {
+ switch (event) {
+ case CallEventActionCallDecline(:final callKitParams):
+ await _notifyCallerDeclined(callKitParams.extra ?? {});
default:
+ // Accept is handled when the app opens (splash_page.dart -> checkVoipCalling via activeCalls).
break;
}
}
+/// Tells the caller the call was declined (same signalling as `_handleCallDeclined` in land_page.dart).
+Future _notifyCallerDeclined(Map extra) async {
+ final callee = extra["calleeEmployeeNumber"]?.toString() ?? '';
+ final caller = extra["callerEmployeeNumber"]?.toString() ?? '';
+ if (callee.isEmpty || caller.isEmpty) return;
+ final provider = CallProvider();
+ try {
+ if (await provider.buildHubConnection(callee)) {
+ await provider.declineCall(callee, caller, moduleId: int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0);
+ }
+ } catch (_) {
+ // Best effort - the caller side also times out.
+ } finally {
+ try {
+ await provider.endSession();
+ } catch (_) {}
+ }
+}
+
class FirebaseNotificationManger {
static FirebaseMessaging messaging = FirebaseMessaging.instance;
static String? token;
static String? voipToken;
static Timer? _missedCallTimer;
static String? _activeMissedCallId;
+
+ /// iOS only. The VoIP token is registered natively in AppDelegate (PushKit) and stored
+ /// by flutter_callkit_incoming. It is sent to the backend as `voIPToken` on chat login.
+ static Future refreshVoipToken() async {
+ if (!Platform.isIOS) return;
+ try {
+ final value = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
+ if (value != null && value.isNotEmpty) voipToken = value;
+ } catch (_) {}
+ }
+
static void cancelMissedCallTimer([String? callId]) {
if (_activeMissedCallId == null) return;
if (callId != null && _activeMissedCallId != callId) return;
@@ -83,6 +101,15 @@ class FirebaseNotificationManger {
_activeMissedCallId = null;
}
+ static final FirebaseNotificationManger _instance = FirebaseNotificationManger._internal();
+
+ FirebaseNotificationManger._internal();
+
+ factory FirebaseNotificationManger() => _instance;
+
+ static FirebaseNotificationManger getInstance() => _instance;
+
+
static Future getToken() async {
NotificationSettings settings = await messaging.requestPermission(alert: true, announcement: false, badge: true, carPlay: false, criticalAlert: false, provisional: false, sound: true);
@@ -112,10 +139,11 @@ class FirebaseNotificationManger {
print("Notification Exception: " + ex.toString());
}
} else {
+ // VoIP token does not depend on notification permission.
+ await refreshVoipToken();
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
try {
token = await messaging.getToken();
- voipToken = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
} catch (ex) {}
}
}
@@ -254,8 +282,14 @@ class FirebaseNotificationManger {
}
}
- static initialized(BuildContext context) async {
-//TOD0 add platform check here also
+ initialized(BuildContext context) async {
+ // iOS incoming calls: VoIP pushes are handled natively in AppDelegate (PushKit -> CallKit
+ // via flutter_callkit_incoming). Accept/decline arrive through FlutterCallkitIncoming.onEvent
+ // (land_page.dart) or, from a cold start, FlutterCallkitIncoming.activeCalls() (splash_page.dart).
+ if (Platform.isIOS) {
+ await refreshVoipToken();
+ }
+
if (!(await isGoogleServicesAvailable()) && Platform.isAndroid) {
var initialNotification = await h_push.Push.getInitialNotification();
if (initialNotification != null) {
@@ -366,18 +400,30 @@ Future handleDataMessage(Map messageData, bool isFromBack
print("handleDataMessage:moduleId:$moduleId");
+ // Android: any string id works. iOS: CallKit silently ignores non-UUID ids, so use a
+ // generated UUID for CallKit and keep the backend id in `extra.callId` (used everywhere else).
+ String callKitId = callId;
+ if (Platform.isIOS) {
+ // Already reported via a VoIP push (AppDelegate)? Don't ring twice.
+ final active = await FlutterCallkitIncoming.activeCalls();
+ if (active.any((c) => c.extra?['callId']?.toString() == callId)) return;
+ if (!_isUuid(callId)) callKitId = _uuidV4();
+ }
+
WidgetsFlutterBinding.ensureInitialized();
FirebaseNotificationManger.cancelMissedCallTimer();
FirebaseNotificationManger._activeMissedCallId = callId;
try {
final callKitParams = CallKitParams(
- id: callId,
+ id: callKitId,
nameCaller: callerName,
appName: 'ATOMS',
avatar: '',
handle: callerEmployeeNumber,
type: isVideoCall ? 1 : 0,
- duration: 30000,
+ // Slightly longer than the 30s missed-call timer below, so that timer (which also sends
+ // NO_ANSWER to the caller) always runs first; the plugin timeout is only a fallback.
+ duration: 32000,
missedCallNotification: const NotificationParams(
showNotification: true,
isShowCallback: false,
@@ -444,7 +490,15 @@ Future handleDataMessage(Map messageData, bool isFromBack
FirebaseNotificationManger._missedCallTimer = Timer(const Duration(seconds: 30), () async {
if (FirebaseNotificationManger._activeMissedCallId != callId) return;
- await FlutterCallkitIncoming.endCall(callId);
+ // This timer may live in a background isolate that cannot be cancelled from the
+ // decline/accept handlers, so re-check: only a call that is still ringing is "missed".
+ final stillRinging = (await FlutterCallkitIncoming.activeCalls()).any((c) => c.id == callKitId && !c.isAccepted);
+ if (!stillRinging) {
+ FirebaseNotificationManger.cancelMissedCallTimer(callId);
+ return;
+ }
+
+ await FlutterCallkitIncoming.endCall(callKitId);
await FlutterCallkitIncoming.endAllCalls();
await FlutterCallkitIncoming.showMissCallNotification(callKitParams);
try {
@@ -470,3 +524,18 @@ Future handleDataMessage(Map messageData, bool isFromBack
}
}
}
+
+final RegExp _uuidPattern = RegExp(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$');
+
+bool _isUuid(String value) => _uuidPattern.hasMatch(value);
+
+/// RFC 4122 version-4 UUID (avoids adding a dependency for one call site).
+String _uuidV4() {
+ final rnd = Random.secure();
+ final b = List.generate(16, (_) => rnd.nextInt(256));
+ b[6] = (b[6] & 0x0f) | 0x40;
+ b[8] = (b[8] & 0x3f) | 0x80;
+ final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
+ return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-${h.substring(16, 20)}-${h.substring(20)}';
+}
+
diff --git a/lib/new_views/pages/land_page/land_page.dart b/lib/new_views/pages/land_page/land_page.dart
index 9d90d8c5..0e4b51ba 100644
--- a/lib/new_views/pages/land_page/land_page.dart
+++ b/lib/new_views/pages/land_page/land_page.dart
@@ -139,7 +139,7 @@ class _LandPageState extends State {
const MyAssetsPage(fromBottomBar: true),
];
if (isFCM) {
- FirebaseNotificationManger.initialized(context);
+ FirebaseNotificationManger().initialized(context);
callingEvents(context);
NotificationManger.initialisation((notificationDetails) {
FirebaseNotificationManger.handleMessage(context, json.decode(notificationDetails.payload!));
@@ -229,44 +229,15 @@ class _LandPageState extends State {
case CallEventActionCallDecline(:final callKitParams):
_handleCallDeclined(callKitParams);
- case CallEventActionDidUpdateDevicePushTokenVoip():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallIncoming(:final callKitParams):
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallStart():
- // TODO: Handle this case.
- throw UnimplementedError();
case CallEventActionCallEnded(:final callKitParams):
_handleCallEnded(callKitParams);
- case CallEventActionCallTimeout():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallConnected():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallCallback():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallToggleHold():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallToggleMute():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallToggleDmtf():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallToggleGroup():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallToggleAudioSession():
- // TODO: Handle this case.
- throw UnimplementedError();
- case CallEventActionCallCustom():
- // TODO: Handle this case.
- throw UnimplementedError();
+
+ case CallEventActionDidUpdateDevicePushTokenVoip():
+ FirebaseNotificationManger.refreshVoipToken();
+
+ // Informational events (several fire on every iOS call) - nothing to do.
+ default:
+ break;
}
});
}
@@ -291,8 +262,14 @@ class _LandPageState extends State {
void _handleCallAccepted(CallKitParams callKitParams) async {
FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?);
- FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]);
- FlutterCallkitIncoming.endAllCalls();
+ // iOS: keep the CallKit call alive for the whole call (keeps the app running and the mic
+ // available when answered from the lock screen); it is ended after the call page closes.
+ if (Platform.isIOS) {
+ await FlutterCallkitIncoming.setCallConnected(callKitParams.id);
+ } else {
+ FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]);
+ FlutterCallkitIncoming.endAllCalls();
+ }
final extra = callKitParams.extra ?? {};
final isVideoCallValue = extra["isVideoCall"];
@@ -311,6 +288,7 @@ class _LandPageState extends State {
}
if (!permissionGranted) {
+ if (Platform.isIOS) FlutterCallkitIncoming.endAllCalls();
try {
final calPro = Provider.of(context, listen: false);
final isConnected = await calPro.buildHubConnection(calleeEmployeeNumber);
@@ -331,9 +309,10 @@ class _LandPageState extends State {
}
if (!mounted) return;
- Navigator.of(context).push(MaterialPageRoute(
+ await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => isVideoCall
? VideoCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true)
: AudioCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true)));
+ if (Platform.isIOS) FlutterCallkitIncoming.endAllCalls();
}
}
diff --git a/lib/new_views/pages/splash_page.dart b/lib/new_views/pages/splash_page.dart
index a7d605f5..6c263765 100644
--- a/lib/new_views/pages/splash_page.dart
+++ b/lib/new_views/pages/splash_page.dart
@@ -18,6 +18,7 @@ import 'package:test_sa/controllers/providers/api/user_provider.dart';
import 'package:test_sa/controllers/providers/settings/setting_provider.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/modules/cx_module/chat/calling/audio_call_page.dart';
+import 'package:test_sa/modules/cx_module/chat/calling/video_call_page.dart';
import 'package:test_sa/modules/cx_module/chat/model/chat_participant_model.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/app_lazy_loading.dart';
@@ -258,12 +259,16 @@ class _SplashPageState extends State {
final String? callUuid = lastCall.id;
await FlutterCallkitIncoming.setCallConnected(callUuid!);
+ final extra = lastCall.extra ?? {};
+ final sender = Participants(employeeNumber: extra["calleeEmployeeNumber"]?.toString());
+ final recipient = Participants(employeeNumber: extra["callerEmployeeNumber"]?.toString());
+ final moduleId = int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0;
+ final referenceID = int.tryParse(extra["referenceId"]?.toString() ?? '');
+ final isVideoCall = extra["isVideoCall"] == true || extra["isVideoCall"]?.toString().toLowerCase() == 'true';
await Navigator.of(context).push(MaterialPageRoute(
- builder: (_) => AudioCallPage(
- sender: Participants(employeeNumber: lastCall.extra?["calleeEmployeeNumber"]),
- recipient: Participants(employeeNumber: lastCall.extra?["callerEmployeeNumber"]),
- moduleId: int.parse(lastCall.extra!["moduleId"]),
- isAccept: true)));
+ builder: (_) => isVideoCall
+ ? VideoCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true)
+ : AudioCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true)));
FlutterCallkitIncoming.endCall(lastCall.extra?["callId"]);
FlutterCallkitIncoming.endAllCalls();
}
diff --git a/pubspec.lock b/pubspec.lock
index 2e49098a..932a34e3 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.12.32"
+ app_badge_control_flutter:
+ dependency: "direct main"
+ description:
+ name: app_badge_control_flutter
+ sha256: "6df220ab6236e1401488f6e756379dc555ebc76f509597b32dc5cd3bda3f7a6e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.2"
archive:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index ca5b0e3e..5950da53 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -111,6 +111,7 @@ dependencies:
sn_progress_dialog: ^1.2.0
flutter_webrtc: ^1.5.2
flutter_callkit_incoming: ^3.1.3
+ app_badge_control_flutter: ^0.1.2
dev_dependencies:
flutter_test: