diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ef59ea7f..dd8e32f7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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], + ] + } } diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index 5db8b611..9ae872d6 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -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 firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future 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 onBackgroundMessage(CallEvent event) async { } /// Tells the caller the call was declined (same signalling as `_handleCallDeclined` in land_page.dart). -Future _notifyCallerDeclined(Map extra) async { +Future notifyCallerDeclined(Map 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 handleDataMessage(Map 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 handleDataMessage(Map 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 handleDataMessage(Map 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 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 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 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( + 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(); diff --git a/lib/new_views/pages/land_page/land_page.dart b/lib/new_views/pages/land_page/land_page.dart index 0e4b51ba..c2e8eccd 100644 --- a/lib/new_views/pages/land_page/land_page.dart +++ b/lib/new_views/pages/land_page/land_page.dart @@ -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 { Future _handleCallDeclined(CallKitParams callKitParams) async { FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?); FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); - var calPro = Provider.of(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 _handleCallEnded(CallKitParams callKitParams) async { @@ -281,30 +274,12 @@ class _LandPageState extends State { final sender = Participants(employeeNumber: calleeEmployeeNumber); final recipient = Participants(employeeNumber: callerEmployeeNumber); if (!mounted) return; - final chatProvider = Provider.of(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(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; } diff --git a/lib/new_views/pages/splash_page.dart b/lib/new_views/pages/splash_page.dart index 6c263765..ef2e496e 100644 --- a/lib/new_views/pages/splash_page.dart +++ b/lib/new_views/pages/splash_page.dart @@ -255,16 +255,23 @@ class _SplashPageState extends State { 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)