improvement

main_production_audio_video_call
Sikander Saleem 2 weeks ago
parent 7ebaa1a919
commit 1b9b20947d

@ -1,13 +1,18 @@
import UIKit
import Flutter
import Firebase
import PushKit
import flutter_callkit_incoming
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate {
// Expose the Flutter engine for SceneDelegate to use
lazy var flutterEngine = FlutterEngine(name: "my flutter engine")
// Must be retained for the lifetime of the app, otherwise VoIP pushes stop.
private var voipRegistry: PKPushRegistry?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@ -21,6 +26,12 @@ import Firebase
// Register plugins with the engine
GeneratedPluginRegistrant.register(with: self.flutterEngine)
// Register for VoIP pushes AFTER plugins, so flutter_callkit_incoming is ready.
let registry = PKPushRegistry(queue: .main)
registry.delegate = self
registry.desiredPushTypes = [.voIP]
voipRegistry = registry
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@ -34,6 +45,130 @@ import Firebase
sceneConfiguration.delegateClass = SceneDelegate.self
return sceneConfiguration
}
// MARK: - PushKit (VoIP)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
guard type == .voIP else { return }
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
// Persisted by the plugin; read in Dart via FlutterCallkitIncoming.getDevicePushTokenVoIP().
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token)
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
guard type == .voIP else { return }
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
// iOS 13+: every VoIP push MUST be reported to CallKit before `completion` is called,
// or iOS terminates the app and eventually stops delivering VoIP pushes to it.
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP, let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
completion()
return
}
let data = flutter_callkit_incoming.Data(args: AppDelegate.callKitArgs(from: payload.dictionaryPayload))
plugin.showCallkitIncoming(data, fromPushKit: true) {
completion()
}
}
/// Builds the same structure Dart's `CallKitParams.toJson()` produces in
/// `handleDataMessage` (firebase_notification_manger.dart), so both paths behave identically.
/// Accepts the call fields at the payload root, or nested under "data"/"extra"/"payload"
/// (as a dictionary or a JSON string).
private static func callKitArgs(from raw: [AnyHashable: Any]) -> [String: Any?] {
var p: [String: Any] = [:]
for (k, v) in raw { if let key = k as? String { p[key] = v } }
for key in ["data", "extra", "payload"] {
if let nested = p[key] as? [String: Any] {
p.merge(nested) { _, new in new }
} else if let json = p[key] as? String,
let d = json.data(using: .utf8),
let nested = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] {
p.merge(nested) { _, new in new }
}
}
func str(_ keys: String...) -> String? {
for k in keys {
if let v = p[k], !(v is NSNull) {
let s = "\(v)"
if !s.isEmpty { return s }
}
}
return nil
}
let callId = str("callId", "sessionId") ?? UUID().uuidString
// CallKit requires a real UUID; the backend id is kept in `extra.callId`.
let uuid = UUID(uuidString: callId)?.uuidString ?? UUID().uuidString
let callerId = str("callerId", "callerEmployeeNumber", "sourceUserId") ?? "unknown"
let callerName = str("callerName", "callerUserName", "userName") ?? "Unknown Caller"
let callerEmployeeNumber = str("callerEmployeeNumber") ?? callerId
let calleeEmployeeNumber = str("calleeEmployeeNumber") ?? callerId
let isVideoCall: Bool = {
if let b = p["isVideoCall"] as? Bool { return b }
if let s = p["isVideoCall"] as? String { return s.lowercased() == "true" }
return str("callType")?.lowercased() == "video"
}()
return [
"id": uuid,
"nameCaller": callerName,
"appName": "ATOMS",
"avatar": "",
"handle": callerEmployeeNumber,
"type": isVideoCall ? 1 : 0,
"duration": 30000,
"extra": [
"callId": callId,
"callerId": callerId,
"callerEmployeeNumber": callerEmployeeNumber,
"calleeEmployeeNumber": calleeEmployeeNumber,
"callerName": callerName,
"moduleId": str("moduleId", "applicationId") ?? "",
"referenceId": str("referenceId") ?? "",
"conversationId": str("conversationId") ?? "",
"isVideoCall": isVideoCall,
"timestamp": ISO8601DateFormatter().string(from: Date()),
] as [String: Any],
"missedCallNotification": [
"showNotification": true,
"isShowCallback": false,
"subtitle": "Missed call",
"callbackText": "Call back",
] as [String: Any],
"callingNotification": [
"showNotification": true,
"isShowCallback": true,
"subtitle": "Calling...",
"callbackText": "Hang Up",
] as [String: Any],
"ios": [
"iconName": "CallKitLogo",
"handleType": "generic",
"supportsVideo": true,
"maximumCallGroups": 1,
"maximumCallsPerCallGroup": 1,
"audioSessionMode": "default",
"audioSessionActive": true,
"audioSessionPreferredSampleRate": 44100.0,
"audioSessionPreferredIOBufferDuration": 0.005,
"supportsDTMF": true,
"supportsHolding": false,
"supportsGrouping": false,
"supportsUngrouping": false,
"ringtonePath": "system_ringtone_default",
] as [String: Any],
]
}
}

@ -14,7 +14,10 @@ import 'package:flutter_callkit_incoming/entities/notification_params.dart';
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:permission_handler/permission_handler.dart' as ph;
import 'package:test_sa/controllers/notification/notification_manger.dart';
import 'package:test_sa/helper/utils.dart';
import 'package:test_sa/views/widgets/dialogs/dialog.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';
@ -50,7 +53,7 @@ Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
Future<void> onBackgroundMessage(CallEvent event) async {
switch (event) {
case CallEventActionCallDecline(:final callKitParams):
await _notifyCallerDeclined(callKitParams.extra ?? {});
await notifyCallerDeclined(callKitParams.extra ?? {});
default:
// Accept is handled when the app opens (splash_page.dart -> checkVoipCalling via activeCalls).
break;
@ -58,14 +61,14 @@ Future<void> onBackgroundMessage(CallEvent event) async {
}
/// Tells the caller the call was declined (same signalling as `_handleCallDeclined` in land_page.dart).
Future<void> _notifyCallerDeclined(Map<String, dynamic> extra) async {
Future<void> notifyCallerDeclined(Map<String, dynamic> extra, {String? reason}) 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);
await provider.declineCall(callee, caller, moduleId: int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0, reason: reason);
}
} catch (_) {
// Best effort - the caller side also times out.
@ -413,6 +416,13 @@ Future<void> handleDataMessage(Map<String, dynamic> messageData, bool isFromBack
WidgetsFlutterBinding.ensureInitialized();
FirebaseNotificationManger.cancelMissedCallTimer();
FirebaseNotificationManger._activeMissedCallId = callId;
// Android 14+: on Accept, the plugin starts its "ongoing call" notification as a foreground
// service of type microphone (+camera for video). Without those runtime permissions Android
// throws a SecurityException and the app crashes before any Dart runs. When permissions are
// missing we skip that notification, so Accept just opens the app and the accept handler
// (land_page / splash_page) asks for permission instead.
final canShowOngoingNotification = !Platform.isAndroid || await hasCallPermissions(isVideoCall);
try {
final callKitParams = CallKitParams(
id: callKitId,
@ -430,8 +440,8 @@ Future<void> handleDataMessage(Map<String, dynamic> messageData, bool isFromBack
subtitle: 'Missed call',
callbackText: 'Call back',
),
callingNotification: const NotificationParams(
showNotification: true,
callingNotification: NotificationParams(
showNotification: canShowOngoingNotification,
isShowCallback: true,
subtitle: 'Calling...',
callbackText: 'Hang Up',
@ -476,6 +486,8 @@ Future<void> handleDataMessage(Map<String, dynamic> messageData, bool isFromBack
ringtonePath: 'system_ringtone_default',
),
);
await FlutterCallkitIncoming.requestNotificationPermission({
// // "title": "Notification permission",
// // "rationaleMessagePermission": "Notification permission is required, to show notification.",
@ -529,6 +541,61 @@ final RegExp _uuidPattern = RegExp(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{
bool _isUuid(String value) => _uuidPattern.hasMatch(value);
/// Permissions needed to answer a call: microphone, plus camera for video.
///
/// Android 14+ (targetSdk 34+): when Accept is tapped, flutter_callkit_incoming natively starts a
/// foreground service of type `microphone` (and `camera` for video). Starting it without these
/// runtime permissions throws a SecurityException that kills the app before any Dart code runs,
/// so the Accept button must never be shown while they are missing.
Future<bool> hasCallPermissions(bool isVideoCall) async {
try {
if (!await ph.Permission.microphone.isGranted) return false;
if (isVideoCall && !await ph.Permission.camera.isGranted) return false;
return true;
} catch (_) {
return false;
}
}
/// Requests call permissions (needs a foreground Activity). Returns true when all are granted.
Future<bool> requestCallPermissions({bool includeCamera = true}) async {
try {
final result = await [ph.Permission.microphone, if (includeCamera) ph.Permission.camera].request();
return result.values.every((s) => s.isGranted);
} catch (_) {
return false;
}
}
/// Used when the user taps Accept: returns true if the call can proceed.
/// Asks for permission if needed; if it was permanently denied (Android stops showing the
/// system prompt), offers to open App Settings instead of failing silently.
Future<bool> ensureCallPermissionsOnAccept(BuildContext context, {required bool isVideoCall}) async {
if (await hasCallPermissions(isVideoCall)) return true;
if (await requestCallPermissions(includeCamera: isVideoCall)) return true;
if (!context.mounted) return false;
final what = isVideoCall ? "Camera and microphone" : "Microphone";
final permanentlyDenied = await ph.Permission.microphone.isPermanentlyDenied || (isVideoCall && await ph.Permission.camera.isPermanentlyDenied);
if (!context.mounted) return false;
if (permanentlyDenied) {
final openSettings = await showDialog<bool>(
context: context,
builder: (_) => AAlertDialog(
title: "Permission Required",
content: "$what access is turned off for ATOMS. Enable it in Settings to answer calls.",
button1Text: "Open Settings",
button2Text: "Cancel",
),
);
if (openSettings == true) await ph.openAppSettings();
} else {
Utils.showToast("$what permission is required to answer calls.");
}
return false;
}
/// RFC 4122 version-4 UUID (avoids adding a dependency for one call site).
String _uuidV4() {
final rnd = Random.secure();

@ -18,9 +18,7 @@ import 'package:test_sa/extensions/string_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/models/enums/user_types.dart';
import 'package:test_sa/modules/cx_module/chat/calling/audio_call_page.dart';
import 'package:test_sa/modules/cx_module/chat/calling/call_provider.dart';
import 'package:test_sa/modules/cx_module/chat/calling/video_call_page.dart';
import 'package:test_sa/modules/cx_module/chat/chat_provider.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_filled_button.dart';
@ -245,13 +243,8 @@ class _LandPageState extends State<LandPage> {
Future<void> _handleCallDeclined(CallKitParams callKitParams) async {
FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?);
FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]);
var calPro = Provider.of<CallProvider>(context, listen: false);
bool isConnected = await calPro.buildHubConnection(callKitParams.extra?["calleeEmployeeNumber"]);
if (isConnected) {
await calPro.declineCall(callKitParams.extra?["calleeEmployeeNumber"], callKitParams.extra?["callerEmployeeNumber"],
moduleId: int.tryParse(callKitParams.extra?["moduleId"]?.toString() ?? '') ?? 0);
calPro.dispose();
}
// Uses its own short-lived CallProvider - the app-wide one must not be disposed.
await notifyCallerDeclined(callKitParams.extra ?? {});
}
Future<void> _handleCallEnded(CallKitParams callKitParams) async {
@ -281,30 +274,12 @@ class _LandPageState extends State<LandPage> {
final sender = Participants(employeeNumber: calleeEmployeeNumber);
final recipient = Participants(employeeNumber: callerEmployeeNumber);
if (!mounted) return;
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
bool permissionGranted = isVideoCall ? await chatProvider.checkVideoPermissions() : await chatProvider.checkAudioPermissions();
if (!permissionGranted) {
permissionGranted = isVideoCall ? await chatProvider.requestVideoPermissions() : await chatProvider.requestAudioPermissions();
}
// Asks for mic (+camera) on Accept; offers Settings if permanently denied.
final permissionGranted = await ensureCallPermissionsOnAccept(context, isVideoCall: isVideoCall);
if (!permissionGranted) {
if (Platform.isIOS) FlutterCallkitIncoming.endAllCalls();
try {
final calPro = Provider.of<CallProvider>(context, listen: false);
final isConnected = await calPro.buildHubConnection(calleeEmployeeNumber);
if (isConnected) {
await calPro.declineCall(calleeEmployeeNumber, callerEmployeeNumber, moduleId: moduleId, reason: 'PERMISSION_DENIED');
}
calPro.dispose();
} catch (_) {
}
if (mounted) {
context.showConfirmDialog(
isVideoCall ? "Camera & microphone permission is required to answer video calls." : "Microphone permission is required to answer calls.",
title: "Permission Required",
);
}
await notifyCallerDeclined(extra, reason: 'PERMISSION_DENIED');
return;
}

@ -255,16 +255,23 @@ class _SplashPageState extends State<SplashPage> {
if (activeCalls is List && activeCalls.isNotEmpty) {
CallKitParams lastCall = activeCalls.last;
if (lastCall.isAccepted) {
final String? callerId = lastCall.extra?['callerId'];
final String? callUuid = lastCall.id;
final extra = lastCall.extra ?? {};
final isVideoCall = extra["isVideoCall"] == true || extra["isVideoCall"]?.toString().toLowerCase() == 'true';
// Asks for mic (+camera) on Accept; offers Settings if permanently denied.
if (!await ensureCallPermissionsOnAccept(context, isVideoCall: isVideoCall)) {
await FlutterCallkitIncoming.endAllCalls();
await notifyCallerDeclined(extra, reason: 'PERMISSION_DENIED');
return;
}
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';
if (!mounted) return;
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => isVideoCall
? VideoCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true)

Loading…
Cancel
Save