diff --git a/.github/actions/pana/action.yml b/.github/actions/pana/action.yml index 286ca63ff..947cd4de5 100644 --- a/.github/actions/pana/action.yml +++ b/.github/actions/pana/action.yml @@ -1,20 +1,21 @@ name: Pana Workflow +description: "Runs pana and checks the score for the package" inputs: min_score: + description: "The minimum pana score required" required: false - type: number - default: 120 + default: "120" pana_version: + description: "The pana version to use" required: false - type: string runs_on: + description: "The runner to use" required: false - type: string default: "ubuntu-latest" working_directory: + description: "The working directory" required: false - type: string default: "." runs: @@ -24,9 +25,21 @@ runs: uses: mikefarah/yq@master with: cmd: | + # --- stream_video_flutter --- yq eval '.dependencies.stream_video = {"path": "../stream_video"}' -i packages/stream_video_flutter/pubspec.yaml + yq eval '.dependency_overrides.stream_video = {"path": "../stream_video"}' -i packages/stream_video_flutter/pubspec.yaml + + # --- stream_video_push_notification --- yq eval '.dependencies.stream_video = {"path": "../stream_video"}' -i packages/stream_video_push_notification/pubspec.yaml + yq eval '.dependencies.stream_video_flutter = {"path": "../stream_video_flutter"}' -i packages/stream_video_push_notification/pubspec.yaml + yq eval '.dependency_overrides.stream_video = {"path": "../stream_video"}' -i packages/stream_video_push_notification/pubspec.yaml + yq eval '.dependency_overrides.stream_video_flutter = {"path": "../stream_video_flutter"}' -i packages/stream_video_push_notification/pubspec.yaml + + # --- stream_video_noise_cancellation --- yq eval '.dependencies.stream_video = {"path": "../stream_video"}' -i packages/stream_video_noise_cancellation/pubspec.yaml + yq eval '.dependencies.stream_video_flutter = {"path": "../stream_video_flutter"}' -i packages/stream_video_noise_cancellation/pubspec.yaml + yq eval '.dependency_overrides.stream_video = {"path": "../stream_video"}' -i packages/stream_video_noise_cancellation/pubspec.yaml + yq eval '.dependency_overrides.stream_video_flutter = {"path": "../stream_video_flutter"}' -i packages/stream_video_noise_cancellation/pubspec.yaml - name: Install Flutter uses: subosito/flutter-action@v2 diff --git a/dogfooding/android/app/proguard-rules.pro b/dogfooding/android/app/proguard-rules.pro index b62c31fd2..b3b376ddd 100644 --- a/dogfooding/android/app/proguard-rules.pro +++ b/dogfooding/android/app/proguard-rules.pro @@ -4,8 +4,6 @@ -keep class io.flutter.view.** { *; } -keep class io.flutter.plugins.** { *; } --keep class com.hiennv.flutter_callkit_incoming.** { *; } - -keep class java.beans.Transient.** {*;} -keep class java.beans.ConstructorProperties.** {*;} -keep class java.nio.file.Path.** {*;} diff --git a/dogfooding/assets/logo.png b/dogfooding/assets/logo.png new file mode 100644 index 000000000..62d944d79 Binary files /dev/null and b/dogfooding/assets/logo.png differ diff --git a/dogfooding/lib/app/app_content.dart b/dogfooding/lib/app/app_content.dart index de80946a7..67c4634dd 100644 --- a/dogfooding/lib/app/app_content.dart +++ b/dogfooding/lib/app/app_content.dart @@ -63,7 +63,7 @@ class _StreamDogFoodingAppContentState if (!locator.isRegistered()) return; // Observe call kit events. - _observeCallKitEvents(); + _observeRingingEvents(); // Observes deep links. _observeDeepLinks(); // Observe FCM messages. @@ -114,7 +114,7 @@ class _StreamDogFoodingAppContentState } } - void _observeCallKitEvents() { + void _observeRingingEvents() { final streamVideo = locator.get(); // On mobile we depend on call kit notifications. @@ -122,7 +122,7 @@ class _StreamDogFoodingAppContentState // websocket which can receive a call when the app is open. if (CurrentPlatform.isMobile) { _compositeSubscription.add( - streamVideo.observeCoreCallKitEvents( + streamVideo.observeCoreRingingEvents( onCallAccepted: (callToJoin) { // Navigate to the call screen. final extra = ( diff --git a/dogfooding/lib/app/firebase_messaging_handler.dart b/dogfooding/lib/app/firebase_messaging_handler.dart index 892452569..1108fe774 100644 --- a/dogfooding/lib/app/firebase_messaging_handler.dart +++ b/dogfooding/lib/app/firebase_messaging_handler.dart @@ -37,7 +37,7 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { prefs.environment, ); - final subscription = streamVideo.observeCallDeclinedCallKitEvent(); + final subscription = streamVideo.observeCallDeclinedRingingEvent(); streamVideo.disposeAfterResolvingRinging( disposingCallback: () { diff --git a/dogfooding/lib/di/injector.dart b/dogfooding/lib/di/injector.dart index 2f74d5e09..5299b0c9d 100644 --- a/dogfooding/lib/di/injector.dart +++ b/dogfooding/lib/di/injector.dart @@ -16,7 +16,6 @@ import '../core/repos/token_service.dart'; import '../core/repos/user_auth_repository.dart'; import '../core/repos/user_chat_repository.dart'; import '../log_config.dart'; -import '../utils/consts.dart'; GetIt locator = GetIt.instance; @@ -167,9 +166,9 @@ StreamVideo _initStreamVideo( androidPushProvider: const StreamVideoPushProvider.firebase( name: 'flutter-firebase', ), - pushParams: const StreamVideoPushParams( - appName: kAppName, - ios: IOSParams(iconName: 'IconMask'), + pushConfiguration: const StreamVideoPushConfiguration( + ios: IOSPushConfiguration(iconName: 'IconMask'), + android: AndroidPushConfiguration(defaultAvatar: 'assets/logo.png'), ), registerApnDeviceToken: true, ), diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index ab2a2e34e..375b6098d 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,5 +1,16 @@ ## Unreleased +🚧 Breaking changes + +### API renames and type changes + +- `onCallKitEvent` β†’ `onRingingEvent` +- `observeCoreCallKitEvents` β†’ `observeCoreRingingEvents` +- `observeCallAcceptCallKitEvent` β†’ `observeCallAcceptRingingEvent` +- `observeCallDeclinedCallKitEvent` β†’ `observeCallDeclinedRingingEvent` +- `observeCallEndedCallKitEvent` β†’ `observeCallEndedRingingEvent` +- `CallKitEvent` (type) β†’ `RingingEvent` + πŸ”„ Changed - The `byParticipantSource` participant sorting now accepts a list of sources. The default sorting for `speaker` and `livestream` presets now include other ingress sources. diff --git a/packages/stream_video/lib/fix_data.yaml b/packages/stream_video/lib/fix_data.yaml new file mode 100644 index 000000000..95eaed802 --- /dev/null +++ b/packages/stream_video/lib/fix_data.yaml @@ -0,0 +1,86 @@ +# Docs on data driven fixes: https://github.com/flutter/flutter/blob/master/docs/contributing/Data-driven-Fixes.md + +version: 1 +transforms: + - title: "replace CallKitEvent with RingingEvent" + date: "2025-08-28" + element: + uris: + [ + "package:stream_video/src/push_notification/push_notification_manager.dart", + "package:stream_video/stream_video.dart", + ] + typedef: "CallKitEvent" + changes: + - kind: "rename" + newName: "RingingEvent" + + - title: "replace observeCallDeclinedCallKitEvent with observeCallDeclinedRingingEvent" + date: "2025-08-28" + element: + uris: + [ + "package:stream_video/src/stream_video.dart", + "package:stream_video/stream_video.dart", + ] + inClass: "StreamVideo" + method: "observeCallDeclinedCallKitEvent" + changes: + - kind: "rename" + newName: "observeCallDeclinedRingingEvent" + + - title: "replace onCallKitEvent with onRingingEvent" + date: "2025-08-28" + element: + uris: + [ + "package:stream_video/src/stream_video.dart", + "package:stream_video/stream_video.dart", + ] + inClass: "StreamVideo" + method: "onCallKitEvent" + changes: + - kind: "rename" + newName: "onRingingEvent" + + - title: "replace observeCoreCallKitEvents with observeCoreRingingEvents" + date: "2025-08-28" + element: + uris: + [ + "package:stream_video/src/stream_video.dart", + "package:stream_video/stream_video.dart", + ] + inClass: "StreamVideo" + method: "observeCoreCallKitEvents" + changes: + - kind: "rename" + newName: "observeCoreRingingEvents" + + - title: "replace observeCallAcceptCallKitEvent with observeCallAcceptRingingEvent" + date: "2025-08-28" + element: + uris: + [ + "package:stream_video/src/stream_video.dart", + "package:stream_video/stream_video.dart", + ] + inClass: "StreamVideo" + method: "observeCallAcceptCallKitEvent" + changes: + - kind: "rename" + newName: "observeCallAcceptRingingEvent" + + - title: "replace observeCallEndedCallKitEvent with observeCallEndedRingingEvent" + date: "2025-08-28" + element: + uris: + [ + "package:stream_video/src/stream_video.dart", + "package:stream_video/stream_video.dart", + ] + inClass: "StreamVideo" + method: "observeCallEndedCallKitEvent" + changes: + - kind: "rename" + newName: "observeCallEndedRingingEvent" diff --git a/packages/stream_video/lib/src/push_notification/call_kit_events.dart b/packages/stream_video/lib/src/push_notification/call_kit_events.dart index 100b00e99..25ff21af0 100644 --- a/packages/stream_video/lib/src/push_notification/call_kit_events.dart +++ b/packages/stream_video/lib/src/push_notification/call_kit_events.dart @@ -1,18 +1,21 @@ part of 'push_notification_manager.dart'; -/// Represents an event related to the CallKit. +@Deprecated('Use RingingEvent instead.') +typedef CallKitEvent = RingingEvent; + +/// Represents an event related to the Ringing flow. /// /// Instances of this class are used to signify different call events that can be /// received from [PushNotificationManager]. -sealed class CallKitEvent with EquatableMixin { - const CallKitEvent(); +sealed class RingingEvent with EquatableMixin { + const RingingEvent(); @override bool? get stringify => true; } /// Event for updating the VoIP push token on the device (iOS specific). -class ActionDidUpdateDevicePushTokenVoip extends CallKitEvent { +class ActionDidUpdateDevicePushTokenVoip extends RingingEvent { /// Creates an [ActionDidUpdateDevicePushTokenVoip] event instance with the /// specified [token]. const ActionDidUpdateDevicePushTokenVoip({required this.token}); @@ -27,7 +30,7 @@ class ActionDidUpdateDevicePushTokenVoip extends CallKitEvent { /// Represents an incoming call event. /// /// This event is triggered when a incoming call is received. -class ActionCallIncoming extends CallKitEvent { +class ActionCallIncoming extends RingingEvent { /// Creates an [ActionCallIncoming] event instance with the specified [data]. const ActionCallIncoming({required this.data}); @@ -41,7 +44,7 @@ class ActionCallIncoming extends CallKitEvent { /// Represents a call start event. /// /// This event is triggered when a outgoing call is started. -class ActionCallStart extends CallKitEvent { +class ActionCallStart extends RingingEvent { /// Creates an [ActionCallStart] event instance with the specified [data]. const ActionCallStart({required this.data}); @@ -57,7 +60,7 @@ class ActionCallStart extends CallKitEvent { /// This event is triggered when a incoming call is accepted. This can happen /// when a user clicks on the "Accept" action from a incoming call /// notification. -class ActionCallAccept extends CallKitEvent { +class ActionCallAccept extends RingingEvent { /// Creates an [ActionCallAccept] event instance with the specified [data]. const ActionCallAccept({required this.data}); @@ -73,7 +76,7 @@ class ActionCallAccept extends CallKitEvent { /// This event is triggered when a incoming call is declined. This can happen /// when a user clicks on the "Decline" action from a incoming call /// notification. -class ActionCallDecline extends CallKitEvent { +class ActionCallDecline extends RingingEvent { /// Creates an [ActionCallDecline] event instance with the specified [data]. const ActionCallDecline({required this.data}); @@ -88,7 +91,7 @@ class ActionCallDecline extends CallKitEvent { /// /// This event is triggered when a incoming or outgoing call is ended. This can /// happen when a user clicks on the "End" action from the notification. -class ActionCallEnded extends CallKitEvent { +class ActionCallEnded extends RingingEvent { /// Creates an [ActionCallEnded] event instance with the specified [data]. const ActionCallEnded({required this.data}); @@ -103,7 +106,7 @@ class ActionCallEnded extends CallKitEvent { /// /// This event is triggered when a call times out. This can happen when a user /// doesn't answer a incoming call within a certain time frame. -class ActionCallTimeout extends CallKitEvent { +class ActionCallTimeout extends RingingEvent { /// Creates an [ActionCallTimeout] event instance with the specified [data]. const ActionCallTimeout({required this.data}); @@ -120,7 +123,7 @@ class ActionCallTimeout extends CallKitEvent { /// user clicks on the "Call back" action from a missed call notification. /// /// Note: This event is only available on Android. -class ActionCallCallback extends CallKitEvent { +class ActionCallCallback extends RingingEvent { /// Creates an [ActionCallCallback] event instance with the specified [data]. const ActionCallCallback({required this.data}); @@ -131,7 +134,7 @@ class ActionCallCallback extends CallKitEvent { List get props => [data]; } -class ActionCallConnected extends CallKitEvent { +class ActionCallConnected extends RingingEvent { /// Creates an [ActionCallConnected] event instance with the specified [data]. const ActionCallConnected({required this.data}); @@ -145,7 +148,7 @@ class ActionCallConnected extends CallKitEvent { /// Represents a call toggle hold event. /// /// Note: This event is only available on iOS. -class ActionCallToggleHold extends CallKitEvent { +class ActionCallToggleHold extends RingingEvent { /// Creates an [ActionCallToggleHold] event instance with the specified [uuid] /// and [isOnHold] flag. const ActionCallToggleHold({ @@ -166,7 +169,7 @@ class ActionCallToggleHold extends CallKitEvent { /// Represents a call toggle mute event. /// /// Note: This event is only available on iOS. -class ActionCallToggleMute extends CallKitEvent { +class ActionCallToggleMute extends RingingEvent { /// Creates an [ActionCallToggleMute] event instance with the specified [uuid] /// and [isMuted] flag. const ActionCallToggleMute({ @@ -184,13 +187,13 @@ class ActionCallToggleMute extends CallKitEvent { List get props => [uuid, isMuted]; } -/// Represents a call toggle DMTF event. +/// Represents a call toggle DTMF event. /// /// Note: This event is only available on iOS. -class ActionCallToggleDmtf extends CallKitEvent { - /// Creates an [ActionCallToggleDmtf] event instance with the specified [uuid] +class ActionCallToggleDtmf extends RingingEvent { + /// Creates an [ActionCallToggleDtmf] event instance with the specified [uuid] /// and [digits]. - const ActionCallToggleDmtf({ + const ActionCallToggleDtmf({ required this.uuid, required this.digits, }); @@ -208,7 +211,7 @@ class ActionCallToggleDmtf extends CallKitEvent { /// Represents a call toggle group event. /// /// Note: This event is only available on iOS. -class ActionCallToggleGroup extends CallKitEvent { +class ActionCallToggleGroup extends RingingEvent { /// Creates an [ActionCallToggleGroup] event instance with the specified /// [uuid] and [callUUIDToGroupWith]. const ActionCallToggleGroup({ @@ -229,20 +232,20 @@ class ActionCallToggleGroup extends CallKitEvent { /// Represents a call toggle audio session event. /// /// Note: This event is only available on iOS. -class ActionCallToggleAudioSession extends CallKitEvent { +class ActionCallToggleAudioSession extends RingingEvent { /// Creates an [ActionCallToggleAudioSession] event instance with the - /// specified [isActivate] flag. - const ActionCallToggleAudioSession({required this.isActivate}); + /// specified [isActive] flag. + const ActionCallToggleAudioSession({required this.isActive}); /// Indicates whether the audio session is active. - final bool isActivate; + final bool isActive; @override - List get props => [isActivate]; + List get props => [isActive]; } /// Represents a custom call event. -class ActionCallCustom extends CallKitEvent { +class ActionCallCustom extends RingingEvent { /// Creates an [ActionCallCustom] event instance with the specified [body]. const ActionCallCustom(this.body); @@ -265,7 +268,7 @@ class CallData with EquatableMixin { this.callCid, this.avatar, this.handle, - this.nameCaller, + this.callerName, this.hasVideo, this.extraData, }); @@ -283,7 +286,7 @@ class CallData with EquatableMixin { final String? handle; /// Name of the caller. - final String? nameCaller; + final String? callerName; /// Indicates whether the call has video. final bool? hasVideo; @@ -300,7 +303,7 @@ class CallData with EquatableMixin { callCid, avatar, handle, - nameCaller, + callerName, hasVideo, extraData, ]; diff --git a/packages/stream_video/lib/src/push_notification/push_notification_manager.dart b/packages/stream_video/lib/src/push_notification/push_notification_manager.dart index c06425216..344519029 100644 --- a/packages/stream_video/lib/src/push_notification/push_notification_manager.dart +++ b/packages/stream_video/lib/src/push_notification/push_notification_manager.dart @@ -18,8 +18,8 @@ typedef PNManagerProvider = /// Interface for managing push notifications related to call events. abstract class PushNotificationManager { - /// Stream of [CallKitEvent] for call-related events. - Stream get onCallEvent; + /// Stream of [RingingEvent] for call-related events. + Stream get onCallEvent; /// Registers the device for push notifications. void registerDevice(); @@ -33,14 +33,14 @@ abstract class PushNotificationManager { /// [callCid] is the call's unique identifier. /// [avatar] is the avatar of the caller. /// [handle] is the handle of the caller. - /// [nameCaller] is the name of the caller. + /// [callerName] is the name of the caller. /// [hasVideo] indicates whether the call has video (default is true). Future showIncomingCall({ required String uuid, required String callCid, String? avatar, String? handle, - String? nameCaller, + String? callerName, bool hasVideo = true, }); @@ -50,14 +50,14 @@ abstract class PushNotificationManager { /// [callCid] is the call's unique identifier. /// [avatar] is the avatar of the caller. /// [handle] is the handle of the caller. - /// [nameCaller] is the name of the caller. + /// [callerName] is the name of the caller. /// [hasVideo] indicates whether the call has video (default is true). Future showMissedCall({ required String uuid, required String callCid, String? avatar, String? handle, - String? nameCaller, + String? callerName, bool hasVideo = true, }); @@ -67,14 +67,14 @@ abstract class PushNotificationManager { /// [callCid] is the call's unique identifier. /// [avatar] is the avatar of the caller. /// [handle] is the handle of the caller. - /// [nameCaller] is the name of the caller. + /// [callerName] is the name of the caller. /// [hasVideo] indicates whether the call has video (default is true). Future startOutgoingCall({ required String uuid, required String callCid, String? avatar, String? handle, - String? nameCaller, + String? callerName, bool hasVideo = true, }); @@ -122,7 +122,7 @@ abstract class PushNotificationManager { } extension NotificationManagerExtension on PushNotificationManager { - StreamSubscription on( + StreamSubscription on( void Function(T event)? onEvent, ) { return onCallEvent.whereType().listen(onEvent); diff --git a/packages/stream_video/lib/src/sorting/call_participant_sorting_presets.dart b/packages/stream_video/lib/src/sorting/call_participant_sorting_presets.dart index 557c63d46..2dde9672a 100644 --- a/packages/stream_video/lib/src/sorting/call_participant_sorting_presets.dart +++ b/packages/stream_video/lib/src/sorting/call_participant_sorting_presets.dart @@ -1,5 +1,4 @@ import '../models/call_participant_state.dart'; -import '../sfu/data/models/sfu_participant_source.dart'; import 'call_participant_state_sorting.dart'; mixin CallParticipantSortingPresets { diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index 45d240a7e..ba486832d 100644 --- a/packages/stream_video/lib/src/stream_video.dart +++ b/packages/stream_video/lib/src/stream_video.dart @@ -697,22 +697,22 @@ class StreamVideo extends Disposable { return result; } - StreamSubscription? onCallKitEvent( + StreamSubscription? onRingingEvent( void Function(T event)? onEvent, ) { final manager = pushNotificationManager; if (manager == null) { - _logger.e(() => '[onCallKitEvent] rejected (no manager)'); + _logger.e(() => '[onRingingEvent] rejected (no manager)'); return null; } return manager.on(onEvent); } - StreamSubscription? disposeAfterResolvingRinging({ + StreamSubscription? disposeAfterResolvingRinging({ void Function()? disposingCallback, }) { - return onCallKitEvent( + return onRingingEvent( (event) { if (event is ActionCallAccept || event is ActionCallDecline || @@ -759,28 +759,50 @@ class StreamVideo extends Disposable { return false; } + @Deprecated('Use observeCoreRingingEvents instead.') CompositeSubscription observeCoreCallKitEvents({ void Function(Call)? onCallAccepted, CallPreferences? acceptCallPreferences, }) { - final callKitEventSubscriptions = CompositeSubscription(); + return observeCoreRingingEvents( + onCallAccepted: onCallAccepted, + acceptCallPreferences: acceptCallPreferences, + ); + } + + CompositeSubscription observeCoreRingingEvents({ + void Function(Call)? onCallAccepted, + CallPreferences? acceptCallPreferences, + }) { + final ringingEventSubscriptions = CompositeSubscription(); - observeCallAcceptCallKitEvent( + observeCallAcceptRingingEvent( onCallAccepted: onCallAccepted, acceptCallPreferences: acceptCallPreferences, - )?.addTo(callKitEventSubscriptions); + )?.addTo(ringingEventSubscriptions); - observeCallDeclinedCallKitEvent()?.addTo(callKitEventSubscriptions); - observeCallEndedCallKitEvent()?.addTo(callKitEventSubscriptions); + observeCallDeclinedRingingEvent()?.addTo(ringingEventSubscriptions); + observeCallEndedRingingEvent()?.addTo(ringingEventSubscriptions); - return callKitEventSubscriptions; + return ringingEventSubscriptions; } + @Deprecated('Use observeCallAcceptRingingEvent instead.') StreamSubscription? observeCallAcceptCallKitEvent({ void Function(Call)? onCallAccepted, CallPreferences? acceptCallPreferences, }) { - return onCallKitEvent( + return observeCallAcceptRingingEvent( + onCallAccepted: onCallAccepted, + acceptCallPreferences: acceptCallPreferences, + ); + } + + StreamSubscription? observeCallAcceptRingingEvent({ + void Function(Call)? onCallAccepted, + CallPreferences? acceptCallPreferences, + }) { + return onRingingEvent( (event) => _onCallAccept( event, onCallAccepted: onCallAccepted, @@ -789,12 +811,22 @@ class StreamVideo extends Disposable { ); } + @Deprecated('Use observeCallDeclinedRingingEvent instead.') StreamSubscription? observeCallDeclinedCallKitEvent() { - return onCallKitEvent(_onCallDecline); + return observeCallDeclinedRingingEvent(); + } + + StreamSubscription? observeCallDeclinedRingingEvent() { + return onRingingEvent(_onCallDecline); } + @Deprecated('Use observeCallEndedRingingEvent instead.') StreamSubscription? observeCallEndedCallKitEvent() { - return onCallKitEvent(_onCallEnded); + return observeCallEndedRingingEvent(); + } + + StreamSubscription? observeCallEndedRingingEvent() { + return onRingingEvent(_onCallEnded); } Future _onCallAccept( @@ -855,7 +887,7 @@ class StreamVideo extends Disposable { } } - /// ActionCallEnded event is sent by `flutter_callkit_incoming` when the call is ended. + /// ActionCallEnded event is sent by native side of stream_video_push_notification package when the call is ended. /// On iOS this is connected to CallKit and should end active call or reject incoming call. /// On Android this is connected to push notification being dismissed. /// When app is terminated it can be send even when accepting the call. That's why we only handle it on iOS. @@ -952,7 +984,7 @@ class StreamVideo extends Disposable { manager.showMissedCall( uuid: callUUID, handle: createdById, - nameCaller: (callDisplayName?.isNotEmpty ?? false) + callerName: (callDisplayName?.isNotEmpty ?? false) ? callDisplayName : createdByName, callCid: callCid, @@ -975,7 +1007,7 @@ class StreamVideo extends Disposable { manager.showIncomingCall( uuid: callUUID, handle: createdById, - nameCaller: (callDisplayName?.isNotEmpty ?? false) + callerName: (callDisplayName?.isNotEmpty ?? false) ? callDisplayName : createdByName, callCid: callCid, diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 8b2611c07..292b12dfc 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -1,3 +1,24 @@ +# Unreleased + +🚧 Breaking changes + +In this release, we removed the dependency on `flutter_callkit_incoming`, which introduces breaking changes in the CallKit and ringing functionality: + +* **CallKit/ringing configuration:** The setup flow has changed. Replace the `pushParams` parameter in `StreamVideoPushNotificationManager` with `pushConfiguration` (`StreamVideoPushConfiguration`). Refer to the documentation for detailed parameter mapping. +* **Parameter renaming:** The `nameCaller` parameter has been renamed to `callerName` in various places. +* **Removed properties:** + * The deprecated `callerCustomizationCallback` and `backgroundVoipCallHandler` have been removed from `StreamVideoPushNotificationManager`. + * The `appName` previously used in `pushParams` configuration is now removed as it’s deprecated. The `ProductName` from build settings will be used instead (iOS only). + +### API renames and type changes + +- `onCallKitEvent` β†’ `onRingingEvent` +- `observeCoreCallKitEvents` β†’ `observeCoreRingingEvents` +- `observeCallAcceptCallKitEvent` β†’ `observeCallAcceptRingingEvent` +- `observeCallDeclinedCallKitEvent` β†’ `observeCallDeclinedRingingEvent` +- `observeCallEndedCallKitEvent` β†’ `observeCallEndedRingingEvent` +- `CallKitEvent` (type) β†’ `RingingEvent` + ## 0.11.0 🚧 Build breaking changes diff --git a/packages/stream_video_flutter/android/build.gradle b/packages/stream_video_flutter/android/build.gradle index 8885c4d0a..352bc893f 100644 --- a/packages/stream_video_flutter/android/build.gradle +++ b/packages/stream_video_flutter/android/build.gradle @@ -38,8 +38,8 @@ android { dependencies { implementation 'io.getstream:stream-log:1.3.4' - implementation 'com.squareup.picasso:picasso:2.71828' - implementation 'androidx.core:core:1.16.0' + implementation "io.coil-kt:coil:1.4.0" + implementation 'androidx.core:core:1.17.0' implementation 'androidx.media:media:1.7.0' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2' diff --git a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/StreamNotificationBuilderImpl.kt b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/StreamNotificationBuilderImpl.kt index 7313b1796..0e70a3ea2 100644 --- a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/StreamNotificationBuilderImpl.kt +++ b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/StreamNotificationBuilderImpl.kt @@ -27,8 +27,8 @@ import android.view.View import android.widget.RemoteViews import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat -import com.squareup.picasso.OkHttp3Downloader -import com.squareup.picasso.Picasso +import coil.ImageLoader +import coil.request.ImageRequest import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import io.getstream.video.flutter.stream_video_flutter.R @@ -39,7 +39,6 @@ import io.getstream.video.flutter.stream_video_flutter.service.notification.imag import io.getstream.video.flutter.stream_video_flutter.service.utils.applicationName import io.getstream.video.flutter.stream_video_flutter.service.utils.notificationManager import kotlinx.coroutines.CoroutineScope -import okhttp3.Headers import okhttp3.OkHttpClient private const val TAG = "StreamNtfBuilder" @@ -163,10 +162,7 @@ internal class StreamNotificationBuilderImpl( if (!avatarUrl.isNullOrEmpty()) { logger.i { "[loadAvatar] avatarUrl: $avatarUrl" } val headers = payload.options.avatar.httpHeaders - context.getPicassoInstance(headers) - .load(avatarUrl) - .transform(CircleTransform()) - .into(defaultTarget(builder = this)) + context.loadImageWithCoil(avatarUrl, headers, defaultTarget(builder = this)) } } @@ -213,16 +209,15 @@ internal class StreamNotificationBuilderImpl( if (!avatarUrl.isNullOrEmpty()) { logger.i { "[loadAvatar] avatarUrl: $avatarUrl" } val headers = payload.options.avatar.httpHeaders - context.getPicassoInstance(headers) - .load(avatarUrl) - .transform(CircleTransform()) - .into( - customTarget( - builder = this, - notificationLargeLayout = notificationLargeLayout, - notificationSmallLayout = notificationSmallLayout - ) + context.loadImageWithCoil( + avatarUrl, + headers, + customTarget( + builder = this, + notificationLargeLayout = notificationLargeLayout, + notificationSmallLayout = notificationSmallLayout ) + ) } } @@ -274,9 +269,7 @@ internal class StreamNotificationBuilderImpl( val avatarUrl = payload.options?.avatar?.url if (!avatarUrl.isNullOrEmpty()) { val headers = payload.options.avatar.httpHeaders - context.getPicassoInstance(headers).load(avatarUrl) - .transform(CircleTransform()) - .into(customTarget) + context.loadImageWithCoil(avatarUrl, headers, customTarget) } return this @@ -285,28 +278,37 @@ internal class StreamNotificationBuilderImpl( } private fun useSmallExLayout(): Boolean { - val isCustomSmallExNotification = false return Build.MANUFACTURER.equals( "Samsung", ignoreCase = true - ) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || isCustomSmallExNotification + ) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S } -private fun Context.getPicassoInstance(headers: Map): Picasso { - StreamLog.d(TAG) { "[interceptRequest] headers: $headers" } +private fun Context.loadImageWithCoil(url: String, headers: Map, target: coil.target.Target) { + StreamLog.d(TAG) { "[loadImageWithCoil] headers: $headers" } val client = OkHttpClient.Builder() .addInterceptor { chain -> StreamLog.v(TAG) { "[interceptRequest] request: ${chain.request()}" } val newRequest = chain.request() .newBuilder() - .headers(Headers.of(headers)) - .build() - chain.proceed(newRequest) + headers.forEach { (key, value) -> + newRequest.addHeader(key, value) + } + chain.proceed(newRequest.build()) } .build() - return Picasso.Builder(this) - .downloader(OkHttp3Downloader(client)) + + val imageLoader = ImageLoader.Builder(this) + .okHttpClient(client) + .build() + + val request = ImageRequest.Builder(this) + .data(url) + .transformations(CircleTransform()) + .target(target) .build() + + imageLoader.enqueue(request) } class NotificationLayout( diff --git a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CircleTransform.kt b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CircleTransform.kt index 23700522d..92abcc9b9 100644 --- a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CircleTransform.kt +++ b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CircleTransform.kt @@ -1,38 +1,47 @@ package io.getstream.video.flutter.stream_video_flutter.service.notification.image -import android.graphics.Bitmap -import android.graphics.BitmapShader -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Shader -import com.squareup.picasso.Transformation +import android.graphics.* +import coil.bitmap.BitmapPool +import coil.size.Size +import coil.transform.Transformation +import kotlin.math.min + class CircleTransform : Transformation { - override fun transform(source: Bitmap): Bitmap { - val size = minOf(source.width, source.height) - val x = (source.width - size) / 2 - val y = (source.height - size) / 2 - val squaredBitmap = Bitmap.createBitmap(source, x, y, size, size) - if (squaredBitmap != source) { - source.recycle() - } - val config = source.config ?: Bitmap.Config.ARGB_8888 - val bitmap = Bitmap.createBitmap(size, size, config) - val canvas = Canvas(bitmap) - val paint = Paint() - val shader = BitmapShader( - squaredBitmap, - Shader.TileMode.CLAMP, Shader.TileMode.CLAMP - ) - paint.shader = shader - paint.isAntiAlias = true - val r = size / 2f - canvas.drawCircle(r, r, r, paint) - squaredBitmap.recycle() - return bitmap - } override fun key(): String { return "circle" } + + override suspend fun transform(pool: BitmapPool, input: Bitmap, size: Size): Bitmap { + if (input.isRecycled) return input + + val sizeImage = min(input.width, input.height) + if (sizeImage <= 0) return input + + val x = (input.width - sizeImage) / 2 + val y = (input.height - sizeImage) / 2 + + val squaredBitmap = if (x != 0 || y != 0 || sizeImage != input.width || sizeImage != input.height) { + Bitmap.createBitmap(input, x, y, sizeImage, sizeImage) + } else { + input + } + + val config = squaredBitmap.config ?: Bitmap.Config.ARGB_8888 + val output = pool.get(sizeImage, sizeImage, config).apply { + setHasAlpha(true) + eraseColor(Color.TRANSPARENT) + } + + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = BitmapShader(squaredBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + } + + val radius = sizeImage / 2f + canvas.drawCircle(radius, radius, radius, paint) + + return output + } } \ No newline at end of file diff --git a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CustomTarget.kt b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CustomTarget.kt index 2e000fbb3..43ecd5457 100644 --- a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CustomTarget.kt +++ b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/CustomTarget.kt @@ -1,12 +1,12 @@ package io.getstream.video.flutter.stream_video_flutter.service.notification.image import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.view.View import android.widget.RemoteViews import androidx.core.app.NotificationCompat -import com.squareup.picasso.Picasso -import com.squareup.picasso.Target +import coil.target.Target import io.getstream.log.taggedLogger import io.getstream.video.flutter.stream_video_flutter.R import io.getstream.video.flutter.stream_video_flutter.service.notification.IdentifiedNotification @@ -17,30 +17,31 @@ class CustomTarget( private val onUpdate: (IdentifiedNotification) -> Unit, ) : Target, Function3 { - private val logger by taggedLogger("StreamPicassoTC") + private val logger by taggedLogger("StreamCoilTC") private var builder: NotificationCompat.Builder? = null private var notificationSmallLayout: NotificationLayout? = null private var notificationLargeLayout: NotificationLayout? = null - override fun onBitmapLoaded(bitmap: Bitmap?, from: Picasso.LoadedFrom?) { - logger.v { "[onBitmapLoaded] bitmap.byteCount: ${bitmap?.byteCount}" } + override fun onSuccess(result: Drawable) { + logger.v { "[onSuccess] result: $result" } val builder = builder ?: return - val icon = bitmap ?: return - notificationLargeLayout?.setAvatar(icon) - notificationSmallLayout?.setAvatar(icon) + val bitmap = (result as? BitmapDrawable)?.bitmap ?: return + logger.v { "[onSuccess] bitmap.byteCount: ${bitmap.byteCount}" } + notificationLargeLayout?.setAvatar(bitmap) + notificationSmallLayout?.setAvatar(bitmap) val notification = IdentifiedNotification(notificationId, builder.build()) - logger.v { "[onBitmapLoaded] notification: $notification" } + logger.v { "[onSuccess] notification: $notification" } onUpdate(notification) } - override fun onBitmapFailed(e: Exception?, errorDrawable: Drawable?) { - logger.e { "[onBitmapFailed] error: $e" } + override fun onError(error: Drawable?) { + logger.e { "[onError] error: $error" } } - override fun onPrepareLoad(placeHolderDrawable: Drawable?) { - logger.d { "[onPrepareLoad] placeHolderDrawable: $placeHolderDrawable" } + override fun onStart(placeholder: Drawable?) { + logger.d { "[onStart] placeholder: $placeholder" } } override fun invoke( diff --git a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/DefaultTarget.kt b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/DefaultTarget.kt index 6d6f23843..e78b5881a 100644 --- a/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/DefaultTarget.kt +++ b/packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/notification/image/DefaultTarget.kt @@ -1,10 +1,10 @@ package io.getstream.video.flutter.stream_video_flutter.service.notification.image import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.core.app.NotificationCompat -import com.squareup.picasso.Picasso -import com.squareup.picasso.Target +import coil.target.Target import io.getstream.log.taggedLogger import io.getstream.video.flutter.stream_video_flutter.service.notification.IdentifiedNotification @@ -13,26 +13,27 @@ class DefaultTarget( private val onUpdate: (IdentifiedNotification) -> Unit, ) : Target, Function1 { - private val logger by taggedLogger("StreamPicassoTD") + private val logger by taggedLogger("StreamCoilTD") private var builder: NotificationCompat.Builder? = null - override fun onBitmapLoaded(bitmap: Bitmap?, from: Picasso.LoadedFrom?) { - logger.v { "[onBitmapLoaded] bitmap.byteCount: ${bitmap?.byteCount}" } + override fun onSuccess(result: Drawable) { + logger.v { "[onSuccess] result: $result" } val builder = builder ?: return - val icon = bitmap ?: return - builder.setLargeIcon(icon) + val bitmap = (result as? BitmapDrawable)?.bitmap ?: return + logger.v { "[onSuccess] bitmap.byteCount: ${bitmap.byteCount}" } + builder.setLargeIcon(bitmap) val notification = IdentifiedNotification(notificationId, builder.build()) - logger.v { "[onBitmapLoaded] notification: $notification" } + logger.v { "[onSuccess] notification: $notification" } onUpdate(notification) } - override fun onBitmapFailed(e: Exception?, errorDrawable: Drawable?) { - logger.e { "[onBitmapFailed] error: $e" } + override fun onError(error: Drawable?) { + logger.e { "[onError] error: $error" } } - override fun onPrepareLoad(placeHolderDrawable: Drawable?) { - logger.d { "[onPrepareLoad] placeHolderDrawable: $placeHolderDrawable" } + override fun onStart(placeholder: Drawable?) { + logger.d { "[onStart] placeholder: $placeholder" } } override fun invoke(builder: NotificationCompat.Builder): DefaultTarget { diff --git a/packages/stream_video_flutter/example/android/app/proguard-rules.pro b/packages/stream_video_flutter/example/android/app/proguard-rules.pro index b62c31fd2..b3b376ddd 100644 --- a/packages/stream_video_flutter/example/android/app/proguard-rules.pro +++ b/packages/stream_video_flutter/example/android/app/proguard-rules.pro @@ -4,8 +4,6 @@ -keep class io.flutter.view.** { *; } -keep class io.flutter.plugins.** { *; } --keep class com.hiennv.flutter_callkit_incoming.** { *; } - -keep class java.beans.Transient.** {*;} -keep class java.beans.ConstructorProperties.** {*;} -keep class java.nio.file.Path.** {*;} diff --git a/packages/stream_video_flutter/example/lib/app.dart b/packages/stream_video_flutter/example/lib/app.dart index 24a32d340..4816e8647 100644 --- a/packages/stream_video_flutter/example/lib/app.dart +++ b/packages/stream_video_flutter/example/lib/app.dart @@ -38,7 +38,7 @@ Future _onFirebaseBackgroundMessage(RemoteMessage message) async { userToken: credentials.token, ); - client.observeCallDeclinedCallKitEvent(); + client.observeCallDeclinedRingingEvent(); await _handlePushNotification(message); diff --git a/packages/stream_video_flutter/example/lib/stream_video_sdk.dart b/packages/stream_video_flutter/example/lib/stream_video_sdk.dart index 7bb2b5aa7..0e3cf6246 100644 --- a/packages/stream_video_flutter/example/lib/stream_video_sdk.dart +++ b/packages/stream_video_flutter/example/lib/stream_video_sdk.dart @@ -28,9 +28,8 @@ class StreamVideoSdk { androidPushProvider: const StreamVideoPushProvider.firebase( name: 'flutter-firebase', ), - pushParams: const StreamVideoPushParams( - appName: 'Stream Example', - ios: IOSParams(iconName: 'IconMask'), + pushConfiguration: const StreamVideoPushConfiguration( + ios: IOSPushConfiguration(iconName: 'IconMask'), ), ), options: options, diff --git a/packages/stream_video_flutter/lib/fix_data.yaml b/packages/stream_video_flutter/lib/fix_data.yaml index 1acdcfbea..81f310d64 100644 --- a/packages/stream_video_flutter/lib/fix_data.yaml +++ b/packages/stream_video_flutter/lib/fix_data.yaml @@ -2,307 +2,374 @@ version: 1 transforms: -#region Partial state updates + #region Partial state updates #region StreamCallContainer - - title: 'replace callContentBuilder with callContentWidgetBuilder in StreamCallContainer' - date: '2025-06-06' + - title: "replace callContentBuilder with callContentWidgetBuilder in StreamCallContainer" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamCallContainer' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallContainer" oneOf: - if: "callContentWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'callContentBuilder' - newName: 'callContentWidgetBuilder' + - kind: "renameParameter" + oldName: "callContentBuilder" + newName: "callContentWidgetBuilder" - if: "callContentWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'callContentBuilder' + - kind: "removeParameter" + name: "callContentBuilder" variables: callContentWidgetBuilder: - kind: 'fragment' - value: 'arguments[callContentWidgetBuilder]' - - title: 'replace incomingCallBuilder with incomingCallWidgetBuilder in StreamCallContainer' - date: '2025-06-06' + kind: "fragment" + value: "arguments[callContentWidgetBuilder]" + - title: "replace incomingCallBuilder with incomingCallWidgetBuilder in StreamCallContainer" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamCallContainer' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallContainer" oneOf: - if: "incomingCallWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'incomingCallBuilder' - newName: 'incomingCallWidgetBuilder' + - kind: "renameParameter" + oldName: "incomingCallBuilder" + newName: "incomingCallWidgetBuilder" - if: "incomingCallWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'incomingCallBuilder' + - kind: "removeParameter" + name: "incomingCallBuilder" variables: incomingCallWidgetBuilder: - kind: 'fragment' - value: 'arguments[incomingCallWidgetBuilder]' - - title: 'replace outgoingCallBuilder with outgoingCallWidgetBuilder in StreamCallContainer' - date: '2025-06-06' + kind: "fragment" + value: "arguments[incomingCallWidgetBuilder]" + - title: "replace outgoingCallBuilder with outgoingCallWidgetBuilder in StreamCallContainer" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamCallContainer' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallContainer" oneOf: - if: "outgoingCallWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'outgoingCallBuilder' - newName: 'outgoingCallWidgetBuilder' + - kind: "renameParameter" + oldName: "outgoingCallBuilder" + newName: "outgoingCallWidgetBuilder" - if: "outgoingCallWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'outgoingCallBuilder' + - kind: "removeParameter" + name: "outgoingCallBuilder" variables: outgoingCallWidgetBuilder: - kind: 'fragment' - value: 'arguments[outgoingCallWidgetBuilder]' + kind: "fragment" + value: "arguments[outgoingCallWidgetBuilder]" #endregion StreamCallContainer #region StreamCallContent - - title: 'remove callState from StreamCallContent' - date: '2025-06-06' + - title: "remove callState from StreamCallContent" + date: "2025-06-06" element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallContent" changes: - - kind: 'removeParameter' - name: 'callState' + - kind: "removeParameter" + name: "callState" - - title: 'replace callAppBarBuilder with callAppBarWidgetBuilder in StreamCallContent' - date: '2025-06-06' + - title: "replace callAppBarBuilder with callAppBarWidgetBuilder in StreamCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallContent" oneOf: - if: "callAppBarWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'callAppBarBuilder' - newName: 'callAppBarWidgetBuilder' + - kind: "renameParameter" + oldName: "callAppBarBuilder" + newName: "callAppBarWidgetBuilder" - if: "callAppBarWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'callAppBarBuilder' + - kind: "removeParameter" + name: "callAppBarBuilder" variables: callAppBarWidgetBuilder: - kind: 'fragment' - value: 'arguments[callAppBarWidgetBuilder]' + kind: "fragment" + value: "arguments[callAppBarWidgetBuilder]" - - title: 'replace callParticipantsBuilder with callParticipantsWidgetBuilder in StreamCallContent' - date: '2025-06-06' + - title: "replace callParticipantsBuilder with callParticipantsWidgetBuilder in StreamCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallContent" oneOf: - if: "callParticipantsWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'callParticipantsBuilder' - newName: 'callParticipantsWidgetBuilder' + - kind: "renameParameter" + oldName: "callParticipantsBuilder" + newName: "callParticipantsWidgetBuilder" - if: "callParticipantsWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'callParticipantsBuilder' + - kind: "removeParameter" + name: "callParticipantsBuilder" variables: callParticipantsWidgetBuilder: - kind: 'fragment' - value: 'arguments[callParticipantsWidgetBuilder]' + kind: "fragment" + value: "arguments[callParticipantsWidgetBuilder]" - - title: 'replace callControlsBuilder with callControlsWidgetBuilder in StreamCallContent' - date: '2025-06-06' + - title: "replace callControlsBuilder with callControlsWidgetBuilder in StreamCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallContent" oneOf: - if: "callControlsWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'callControlsBuilder' - newName: 'callControlsWidgetBuilder' + - kind: "renameParameter" + oldName: "callControlsBuilder" + newName: "callControlsWidgetBuilder" - if: "callControlsWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'callControlsBuilder' + - kind: "removeParameter" + name: "callControlsBuilder" variables: callControlsWidgetBuilder: - kind: 'fragment' - value: 'arguments[callControlsWidgetBuilder]' + kind: "fragment" + value: "arguments[callControlsWidgetBuilder]" #endregion StreamCallContent #region StreamIncomingCallContent - - title: 'remove callState from StreamIncomingCallContent' - date: '2025-06-06' + - title: "remove callState from StreamIncomingCallContent" + date: "2025-06-06" element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamIncomingCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamIncomingCallContent" changes: - - kind: 'removeParameter' - name: 'callState' + - kind: "removeParameter" + name: "callState" - - title: 'replace participantsAvatarBuilder with participantsAvatarWidgetBuilder in StreamIncomingCallContent' - date: '2025-06-06' + - title: "replace participantsAvatarBuilder with participantsAvatarWidgetBuilder in StreamIncomingCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamIncomingCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamIncomingCallContent" oneOf: - if: "participantsAvatarWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'participantsAvatarBuilder' - newName: 'participantsAvatarWidgetBuilder' + - kind: "renameParameter" + oldName: "participantsAvatarBuilder" + newName: "participantsAvatarWidgetBuilder" - if: "participantsAvatarWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'participantsAvatarBuilder' + - kind: "removeParameter" + name: "participantsAvatarBuilder" variables: participantsAvatarWidgetBuilder: - kind: 'fragment' - value: 'arguments[participantsAvatarWidgetBuilder]' + kind: "fragment" + value: "arguments[participantsAvatarWidgetBuilder]" - - title: 'replace participantsDisplayNameBuilder with participantsDisplayNameWidgetBuilder in StreamIncomingCallContent' - date: '2025-06-06' + - title: "replace participantsDisplayNameBuilder with participantsDisplayNameWidgetBuilder in StreamIncomingCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamIncomingCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamIncomingCallContent" oneOf: - if: "participantsDisplayNameWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'participantsDisplayNameBuilder' - newName: 'participantsDisplayNameWidgetBuilder' - - if: "participantsDisplayNameWidgetBuilder != ''" + - kind: "renameParameter" + oldName: "participantsDisplayNameBuilder" + newName: "participantsDisplayNameWidgetBuilder" + - if: "participantsDisplayNameWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'participantsDisplayNameBuilder' + - kind: "removeParameter" + name: "participantsDisplayNameBuilder" variables: participantsDisplayNameWidgetBuilder: - kind: 'fragment' - value: 'arguments[participantsDisplayNameWidgetBuilder]' + kind: "fragment" + value: "arguments[participantsDisplayNameWidgetBuilder]" #endregion StreamIncomingCallContent #region StreamOutgoingCallContent - - title: 'remove callState from StreamOutgoingCallContent' - date: '2025-06-06' + - title: "remove callState from StreamOutgoingCallContent" + date: "2025-06-06" element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamOutgoingCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamOutgoingCallContent" changes: - - kind: 'removeParameter' - name: 'callState' - - - title: 'replace callBackgroundBuilder with callBackgroundWidgetBuilder in StreamOutgoingCallContent' - date: '2025-06-06' + - kind: "removeParameter" + name: "callState" + + - title: "replace callBackgroundBuilder with callBackgroundWidgetBuilder in StreamOutgoingCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamOutgoingCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamOutgoingCallContent" oneOf: - if: "callBackgroundWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'callBackgroundBuilder' - newName: 'callBackgroundWidgetBuilder' + - kind: "renameParameter" + oldName: "callBackgroundBuilder" + newName: "callBackgroundWidgetBuilder" - if: "callBackgroundWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'callBackgroundBuilder' + - kind: "removeParameter" + name: "callBackgroundBuilder" variables: callBackgroundWidgetBuilder: - kind: 'fragment' - value: 'arguments[callBackgroundWidgetBuilder]' + kind: "fragment" + value: "arguments[callBackgroundWidgetBuilder]" - - title: 'replace participantsAvatarBuilder with participantsAvatarWidgetBuilder in StreamOutgoingCallContent' - date: '2025-06-06' + - title: "replace participantsAvatarBuilder with participantsAvatarWidgetBuilder in StreamOutgoingCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamOutgoingCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamOutgoingCallContent" oneOf: - if: "participantsAvatarWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'participantsAvatarBuilder' - newName: 'participantsAvatarWidgetBuilder' + - kind: "renameParameter" + oldName: "participantsAvatarBuilder" + newName: "participantsAvatarWidgetBuilder" - if: "participantsAvatarWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'participantsAvatarBuilder' + - kind: "removeParameter" + name: "participantsAvatarBuilder" variables: participantsAvatarWidgetBuilder: - kind: 'fragment' - value: 'arguments[participantsAvatarWidgetBuilder]' + kind: "fragment" + value: "arguments[participantsAvatarWidgetBuilder]" - - title: 'replace participantsDisplayNameBuilder with participantsDisplayNameWidgetBuilder in StreamOutgoingCallContent' - date: '2025-06-06' + - title: "replace participantsDisplayNameBuilder with participantsDisplayNameWidgetBuilder in StreamOutgoingCallContent" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'StreamOutgoingCallContent' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamOutgoingCallContent" oneOf: - if: "participantsDisplayNameWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'participantsDisplayNameBuilder' - newName: 'participantsDisplayNameWidgetBuilder' + - kind: "renameParameter" + oldName: "participantsDisplayNameBuilder" + newName: "participantsDisplayNameWidgetBuilder" - if: "participantsDisplayNameWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'participantsDisplayNameBuilder' + - kind: "removeParameter" + name: "participantsDisplayNameBuilder" variables: participantsDisplayNameWidgetBuilder: - kind: 'fragment' - value: 'arguments[participantsDisplayNameWidgetBuilder]' + kind: "fragment" + value: "arguments[participantsDisplayNameWidgetBuilder]" #endregion StreamOutgoingCallContent #region PictureInPictureConfiguration - - title: 'replace callPictureInPictureBuilder with callPictureInPictureWidgetBuilder in AndroidPictureInPictureConfiguration' - date: '2025-06-06' + - title: "replace callPictureInPictureBuilder with callPictureInPictureWidgetBuilder in AndroidPictureInPictureConfiguration" + date: "2025-06-06" bulkApply: false element: - uris: ['package:stream_video_flutter/stream_video_flutter.dart'] - constructor: '' - inClass: 'AndroidPictureInPictureConfiguration' + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "AndroidPictureInPictureConfiguration" oneOf: - if: "callPictureInPictureWidgetBuilder == ''" changes: - - kind: 'renameParameter' - oldName: 'callPictureInPictureBuilder' - newName: 'callPictureInPictureWidgetBuilder' + - kind: "renameParameter" + oldName: "callPictureInPictureBuilder" + newName: "callPictureInPictureWidgetBuilder" - if: "callPictureInPictureWidgetBuilder != ''" changes: - - kind: 'removeParameter' - name: 'callPictureInPictureBuilder' + - kind: "removeParameter" + name: "callPictureInPictureBuilder" variables: callPictureInPictureWidgetBuilder: - kind: 'fragment' - value: 'arguments[callPictureInPictureWidgetBuilder]' + kind: "fragment" + value: "arguments[callPictureInPictureWidgetBuilder]" #endregion PictureInPictureConfiguration -#endregion \ No newline at end of file + + #region CallKit migration (barrel) + - title: "replace CallKitEvent with RingingEvent" + date: "2025-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + typedef: "CallKitEvent" + changes: + - kind: "rename" + newName: "RingingEvent" + + - title: "replace observeCallDeclinedCallKitEvent with observeCallDeclinedRingingEvent" + date: "2025-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + inClass: "StreamVideo" + method: "observeCallDeclinedCallKitEvent" + changes: + - kind: "rename" + newName: "observeCallDeclinedRingingEvent" + + - title: "replace onCallKitEvent with onRingingEvent" + date: "2025-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + inClass: "StreamVideo" + method: "onCallKitEvent" + changes: + - kind: "rename" + newName: "onRingingEvent" + + - title: "replace observeCoreCallKitEvents with observeCoreRingingEvents" + date: "2025-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + inClass: "StreamVideo" + method: "observeCoreCallKitEvents" + changes: + - kind: "rename" + newName: "observeCoreRingingEvents" + + - title: "replace observeCallAcceptCallKitEvent with observeCallAcceptRingingEvent" + date: "2025-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + inClass: "StreamVideo" + method: "observeCallAcceptCallKitEvent" + changes: + - kind: "rename" + newName: "observeCallAcceptRingingEvent" + + - title: "replace observeCallEndedCallKitEvent with observeCallEndedRingingEvent" + date: "2025-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + inClass: "StreamVideo" + method: "observeCallEndedCallKitEvent" + changes: + - kind: "rename" + newName: "observeCallEndedRingingEvent" + #endregion CallKit migration (barrel) +#endregion diff --git a/packages/stream_video_flutter/pubspec.yaml b/packages/stream_video_flutter/pubspec.yaml index a4df8a5d5..999dee55a 100644 --- a/packages/stream_video_flutter/pubspec.yaml +++ b/packages/stream_video_flutter/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: visibility_detector: ^0.4.0+2 dev_dependencies: - alchemist: ">=0.11.0 <0.13.0" + alchemist: ^0.12.1 build_runner: ^2.8.0 flutter_test: sdk: flutter diff --git a/packages/stream_video_push_notification/CHANGELOG.md b/packages/stream_video_push_notification/CHANGELOG.md index b53e0a984..2fb1b0858 100644 --- a/packages/stream_video_push_notification/CHANGELOG.md +++ b/packages/stream_video_push_notification/CHANGELOG.md @@ -1,3 +1,24 @@ +## Unreleased + +🚧 Breaking changes + +In this release, we removed the dependency on `flutter_callkit_incoming`, which introduces breaking changes in the CallKit and ringing functionality: + +* **CallKit/ringing configuration:** The setup flow has changed. Replace the `pushParams` parameter in `StreamVideoPushNotificationManager` with `pushConfiguration` (`StreamVideoPushConfiguration`). Refer to the documentation for detailed parameter mapping. +* **Parameter renaming:** The `nameCaller` parameter has been renamed to `callerName` in various places. +* **Removed properties:** + * The deprecated `callerCustomizationCallback` and `backgroundVoipCallHandler` have been removed from `StreamVideoPushNotificationManager`. + * The `appName` previously used in `pushParams` configuration is now removed as it’s deprecated. The `ProductName` from build settings will be used instead (iOS only). + +### API renames and type changes + +- `onCallKitEvent` β†’ `onRingingEvent` +- `observeCoreCallKitEvents` β†’ `observeCoreRingingEvents` +- `observeCallAcceptCallKitEvent` β†’ `observeCallAcceptRingingEvent` +- `observeCallDeclinedCallKitEvent` β†’ `observeCallDeclinedRingingEvent` +- `observeCallEndedCallKitEvent` β†’ `observeCallEndedRingingEvent` +- `CallKitEvent` (type) β†’ `RingingEvent` + ## 0.11.0 🚧 Build breaking changes diff --git a/packages/stream_video_push_notification/analysis_options.yaml b/packages/stream_video_push_notification/analysis_options.yaml index 50862cb31..ff9e44733 100644 --- a/packages/stream_video_push_notification/analysis_options.yaml +++ b/packages/stream_video_push_notification/analysis_options.yaml @@ -1,4 +1,5 @@ -# include: package:flutter_lints/flutter.yaml +include: ../../analysis_options.yaml -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options +formatter: + trailing_commas: preserve + page_width: 80 diff --git a/packages/stream_video_push_notification/android/build.gradle b/packages/stream_video_push_notification/android/build.gradle index 7c881fbc5..401c0eebb 100644 --- a/packages/stream_video_push_notification/android/build.gradle +++ b/packages/stream_video_push_notification/android/build.gradle @@ -33,9 +33,17 @@ android { defaultConfig { minSdk = 21 + consumerProguardFiles 'consumer-rules.pro' } dependencies { + implementation 'androidx.core:core-ktx:1.17.0' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'de.hdodenhof:circleimageview:3.1.0' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' + implementation 'com.fasterxml.jackson.core:jackson-core:2.20.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.0' + implementation "io.coil-kt:coil:1.4.0" } testOptions { diff --git a/packages/stream_video_push_notification/android/consumer-rules.pro b/packages/stream_video_push_notification/android/consumer-rules.pro new file mode 100644 index 000000000..fbb7516d7 --- /dev/null +++ b/packages/stream_video_push_notification/android/consumer-rules.pro @@ -0,0 +1,15 @@ +-keep class com.fasterxml.** { *; } +-dontwarn com.fasterxml.jackson.** + +-keepattributes *Annotation* + +-keepclassmembers class * { + @com.fasterxml.jackson.annotation.* ; + @com.fasterxml.jackson.annotation.* ; +} + +-keepclassmembers class * { + public (); +} + +-keep class io.getstream.video.flutter.stream_video_push_notification.** { *; } \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/AndroidManifest.xml b/packages/stream_video_push_notification/android/src/main/AndroidManifest.xml index 856b940b2..aa0935744 100644 --- a/packages/stream_video_push_notification/android/src/main/AndroidManifest.xml +++ b/packages/stream_video_push_notification/android/src/main/AndroidManifest.xml @@ -1,4 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/AppUtils.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/AppUtils.kt new file mode 100644 index 000000000..0549a0dba --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/AppUtils.kt @@ -0,0 +1,15 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.content.Context +import android.content.Intent +import android.os.Bundle + +object AppUtils { + fun getAppIntent(context: Context, action: String? = null, data: Bundle? = null): Intent? { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.cloneFilter() + intent?.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent?.putExtra(StreamVideoPushNotificationPlugin.EXTRA_CALL_CALL_DATA, data) + intent?.action = action + return intent + } +} \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/Call.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/Call.kt new file mode 100644 index 000000000..6f1beee46 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/Call.kt @@ -0,0 +1,344 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.os.Bundle +import com.fasterxml.jackson.annotation.JsonProperty + +@Suppress("UNCHECKED_CAST") +data class Data(val args: Map) { + + constructor() : this(emptyMap()) + + @JsonProperty("id") + var id: String = (args["id"] as? String) ?: "" + + @JsonProperty("uuid") + var uuid: String = (args["id"] as? String) ?: "" + + @JsonProperty("callerName") + var callerName: String = (args["callerName"] as? String) ?: "" + + @JsonProperty("handle") + var handle: String = (args["handle"] as? String) ?: "" + + @JsonProperty("type") + var type: Int = (args["type"] as? Int) ?: 0 + + @JsonProperty("duration") + var duration: Long = + (args["duration"] as? Long) ?: ((args["duration"] as? Int)?.toLong() ?: 30000L) + + @JsonProperty("textAccept") + var textAccept: String = (args["textAccept"] as? String) ?: "" + + @JsonProperty("textDecline") + var textDecline: String = (args["textDecline"] as? String) ?: "" + + @JsonProperty("extra") + var extra: HashMap = + (args["extra"] ?: HashMap()) as HashMap + + @JsonProperty("headers") + var headers: HashMap = + (args["headers"] ?: HashMap()) as HashMap + + @JsonProperty("avatar") + var avatar: String = "" + + @JsonProperty("defaultAvatar") + var defaultAvatar: String = "" + + @JsonProperty("from") + var from: String = "" + + @JsonProperty("fullScreenShowLogo") + var fullScreenShowLogo: Boolean = false + + @JsonProperty("fullScreenLogoUrl") + var fullScreenLogoUrl: String = "" + + @JsonProperty("showCallHandle") + var showCallHandle: Boolean = false + + @JsonProperty("ringtonePath") + var ringtonePath: String + + @JsonProperty("fullScreenBackgroundColor") + var fullScreenBackgroundColor: String = "#0955fa" + + @JsonProperty("fullScreenBackgroundUrl") + var fullScreenBackgroundUrl: String = "" + + @JsonProperty("fullScreenTextColor") + var fullScreenTextColor: String = "#FFFFFF" + + @JsonProperty("incomingCallNotificationChannelName") + var incomingCallNotificationChannelName: String? = null + + @JsonProperty("missedCallNotificationChannelName") + var missedCallNotificationChannelName: String? = null + + @JsonProperty("missedNotificationId") + var missedNotificationId: Int? = null + + @JsonProperty("isShowMissedCallNotification") + var isShowMissedCallNotification: Boolean = true + + @JsonProperty("missedNotificationCount") + var missedNotificationCount: Int = 1 + + @JsonProperty("missedNotificationSubtitle") + var missedNotificationSubtitle: String? = null + + @JsonProperty("missedNotificationCallbackText") + var missedNotificationCallbackText: String? = null + + @JsonProperty("showCallbackButton") + var showCallbackButton: Boolean = true + + @JsonProperty("isAccepted") + var isAccepted: Boolean = false + + @JsonProperty("isOnHold") + var isOnHold: Boolean = (args["isOnHold"] as? Boolean) ?: false + + @JsonProperty("audioRoute") + var audioRoute: Int = (args["audioRoute"] as? Int) ?: 1 + + @JsonProperty("isMuted") + var isMuted: Boolean = (args["isMuted"] as? Boolean) ?: false + + @JsonProperty("showFullScreenOnLockScreen") + var showFullScreenOnLockScreen: Boolean = true + + @JsonProperty("isImportant") + var isImportant: Boolean = false + + @JsonProperty("isBot") + var isBot: Boolean = false + + init { + var android: Map? = args["android"] as? HashMap? + android = android ?: args + avatar = android["avatar"] as? String ?: "" + defaultAvatar = android["defaultAvatar"] as? String ?: "" + ringtonePath = android["ringtonePath"] as? String ?: "" + incomingCallNotificationChannelName = + android["incomingCallNotificationChannelName"] as? String + missedCallNotificationChannelName = android["missedCallNotificationChannelName"] as? String + showFullScreenOnLockScreen = android["showFullScreenOnLockScreen"] as? Boolean ?: true + isImportant = android["isImportant"] as? Boolean ?: false + isBot = android["isBot"] as? Boolean ?: false + + val missedNotification: Map? = + android["missedCallNotification"] as? Map? + + if (missedNotification != null) { + missedNotificationId = missedNotification["id"] as? Int? + missedNotificationSubtitle = missedNotification["subtitle"] as? String? + missedNotificationCount = missedNotification["count"] as? Int? ?: 1 + missedNotificationCallbackText = missedNotification["callbackText"] as? String? + showCallbackButton = missedNotification["showCallbackButton"] as? Boolean ?: true + isShowMissedCallNotification = + missedNotification["showNotification"] as? Boolean ?: true + } + + val incomingNotification: Map? = + android["incomingCallNotification"] as? Map? + + if (incomingNotification != null) { + fullScreenShowLogo = incomingNotification["fullScreenShowLogo"] as? Boolean ?: false + fullScreenLogoUrl = incomingNotification["fullScreenLogoUrl"] as? String? ?: "" + fullScreenBackgroundColor = incomingNotification["fullScreenBackgroundColor"] as? String ?: "#0955fa" + fullScreenBackgroundUrl = incomingNotification["fullScreenBackgroundUrl"] as? String ?: "" + fullScreenTextColor = incomingNotification["fullScreenTextColor"] as? String ?: "#ffffff" + textAccept = incomingNotification["textAccept"] as? String ?: "" + textDecline = incomingNotification["textDecline"] as? String ?: "" + showCallHandle = incomingNotification["showCallHandle"] as? Boolean ?: false + } + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other == null) return false + val e: Data = other as Data + return this.id == e.id + } + + + fun toBundle(): Bundle { + val bundle = Bundle() + bundle.putString(IncomingCallConstants.EXTRA_CALL_ID, id) + bundle.putString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, callerName) + bundle.putString(IncomingCallConstants.EXTRA_CALL_HANDLE, handle) + bundle.putString(IncomingCallConstants.EXTRA_CALL_AVATAR, avatar) + bundle.putString(IncomingCallConstants.EXTRA_CALL_DEFAULT_AVATAR, defaultAvatar) + bundle.putInt(IncomingCallConstants.EXTRA_CALL_TYPE, type) + bundle.putLong(IncomingCallConstants.EXTRA_CALL_DURATION, duration) + bundle.putString(IncomingCallConstants.EXTRA_CALL_TEXT_ACCEPT, textAccept) + bundle.putString(IncomingCallConstants.EXTRA_CALL_TEXT_DECLINE, textDecline) + + missedNotificationId?.let { + bundle.putInt( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID, + it + ) + } + bundle.putBoolean( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SHOW, + isShowMissedCallNotification + ) + bundle.putInt( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_COUNT, + missedNotificationCount + ) + bundle.putString( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SUBTITLE, + missedNotificationSubtitle + ) + bundle.putBoolean( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_SHOW, + showCallbackButton + ) + bundle.putString( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_TEXT, + missedNotificationCallbackText + ) + + bundle.putSerializable(IncomingCallConstants.EXTRA_CALL_EXTRA, extra) + bundle.putSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS, headers) + + bundle.putBoolean( + IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_SHOW_LOGO, + fullScreenShowLogo + ) + bundle.putString( + IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_LOGO_URL, + fullScreenLogoUrl + ) + bundle.putBoolean( + IncomingCallConstants.EXTRA_CALL_SHOW_CALL_HANDLE, + showCallHandle + ) + bundle.putString(IncomingCallConstants.EXTRA_CALL_RINGTONE_PATH, ringtonePath) + bundle.putString( + IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_COLOR, + fullScreenBackgroundColor + ) + bundle.putString( + IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_URL, + fullScreenBackgroundUrl + ) + bundle.putString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_TEXT_COLOR, fullScreenTextColor) + bundle.putString(IncomingCallConstants.EXTRA_CALL_ACTION_FROM, from) + bundle.putString( + IncomingCallConstants.EXTRA_CALL_INCOMING_CALL_NOTIFICATION_CHANNEL_NAME, + incomingCallNotificationChannelName + ) + bundle.putString( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_NOTIFICATION_CHANNEL_NAME, + missedCallNotificationChannelName + ) + bundle.putBoolean( + IncomingCallConstants.EXTRA_CALL_IS_SHOW_FULL_LOCKED_SCREEN, + showFullScreenOnLockScreen + ) + bundle.putBoolean( + IncomingCallConstants.EXTRA_CALL_IS_IMPORTANT, + isImportant, + ) + bundle.putBoolean( + IncomingCallConstants.EXTRA_CALL_IS_BOT, + isBot, + ) + return bundle + } + + companion object { + + fun fromBundle(bundle: Bundle): Data { + val data = Data(emptyMap()) + data.id = bundle.getString(IncomingCallConstants.EXTRA_CALL_ID, "") + data.callerName = + bundle.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, "") + data.handle = + bundle.getString(IncomingCallConstants.EXTRA_CALL_HANDLE, "") + data.avatar = + bundle.getString(IncomingCallConstants.EXTRA_CALL_AVATAR, "") + data.defaultAvatar = + bundle.getString(IncomingCallConstants.EXTRA_CALL_DEFAULT_AVATAR, "") + data.type = bundle.getInt(IncomingCallConstants.EXTRA_CALL_TYPE, 0) + data.duration = + bundle.getLong(IncomingCallConstants.EXTRA_CALL_DURATION, 30000L) + data.textAccept = + bundle.getString(IncomingCallConstants.EXTRA_CALL_TEXT_ACCEPT, "") + data.textDecline = + bundle.getString(IncomingCallConstants.EXTRA_CALL_TEXT_DECLINE, "") + data.isImportant = + bundle.getBoolean(IncomingCallConstants.EXTRA_CALL_IS_IMPORTANT, false) + data.isBot = + bundle.getBoolean(IncomingCallConstants.EXTRA_CALL_IS_BOT, false) + + data.missedNotificationId = + bundle.getInt(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID) + data.isShowMissedCallNotification = + bundle.getBoolean(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SHOW, true) + data.missedNotificationCount = + bundle.getInt(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_COUNT, 1) + data.missedNotificationSubtitle = + bundle.getString(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SUBTITLE, "") + data.showCallbackButton = + bundle.getBoolean(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_SHOW, false) + data.missedNotificationCallbackText = + bundle.getString(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_TEXT, "") + + data.extra = + bundle.getSerializable(IncomingCallConstants.EXTRA_CALL_EXTRA) as HashMap + data.headers = + bundle.getSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS) as HashMap + + data.fullScreenShowLogo = bundle.getBoolean( + IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_SHOW_LOGO, + false + ) + data.fullScreenLogoUrl = + bundle.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_LOGO_URL, "") + data.showCallHandle = bundle.getBoolean( + IncomingCallConstants.EXTRA_CALL_SHOW_CALL_HANDLE, + false + ) + data.ringtonePath = bundle.getString( + IncomingCallConstants.EXTRA_CALL_RINGTONE_PATH, + "" + ) + data.fullScreenBackgroundColor = bundle.getString( + IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_COLOR, + "#0955fa" + ) + data.fullScreenBackgroundUrl = + bundle.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_URL, "") + + data.fullScreenTextColor = bundle.getString( + IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_TEXT_COLOR, + "#FFFFFF" + ) + data.from = + bundle.getString(IncomingCallConstants.EXTRA_CALL_ACTION_FROM, "") + + data.incomingCallNotificationChannelName = bundle.getString( + IncomingCallConstants.EXTRA_CALL_INCOMING_CALL_NOTIFICATION_CHANNEL_NAME + ) + data.missedCallNotificationChannelName = bundle.getString( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_NOTIFICATION_CHANNEL_NAME + ) + data.showFullScreenOnLockScreen = bundle.getBoolean( + IncomingCallConstants.EXTRA_CALL_IS_SHOW_FULL_LOCKED_SCREEN, + true + ) + return data + } + } + +} diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/ImageLoaderProvider.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/ImageLoaderProvider.kt new file mode 100644 index 000000000..69e591026 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/ImageLoaderProvider.kt @@ -0,0 +1,104 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.Log +import android.widget.ImageView +import coil.ImageLoader +import coil.request.ImageRequest +import coil.target.Target +import io.getstream.video.flutter.stream_video_flutter.service.notification.image.CircleTransform +import okhttp3.OkHttpClient + +object ImageLoaderProvider { + @SuppressLint("StaticFieldLeak") + private var instance: ImageLoader? = null + + fun get(context: Context, headers: HashMap?): ImageLoader { + if (instance == null) { + val imageLoader = ImageLoader.Builder(context) + val client = OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .addNetworkInterceptor { chain -> + val newRequestBuilder: okhttp3.Request.Builder = chain.request().newBuilder() + if (headers != null) { + for ((key, value) in headers) { + newRequestBuilder.addHeader(key, value.toString()) + } + } + chain.proceed(newRequestBuilder.build()) + } + .build() + instance = imageLoader.okHttpClient(client).build() + } + return instance!! + } + + fun loadImage(context: Context, url: String, headers: HashMap?, target: Target?) { + val imageLoader = get(context, headers) + val requestBuilder = ImageRequest.Builder(context) + headers?.forEach { (key, value) -> + value?.toString()?.let { + requestBuilder.addHeader(key, it) + } + } + requestBuilder.data(url) + requestBuilder.allowHardware(false) + requestBuilder.transformations(CircleTransform()) + requestBuilder.target(target) + + imageLoader.enqueue(requestBuilder.build()) + } + + fun loadImage(context: Context, url: String, headers: HashMap?, placeholder: Int, target: ImageView) { + val imageLoader = get(context, headers) + val requestBuilder = ImageRequest.Builder(context) + headers?.forEach { (key, value) -> + value?.toString()?.let { + requestBuilder.addHeader(key, it) + } + } + requestBuilder.data(url) + requestBuilder.allowHardware(false) + requestBuilder.placeholder(placeholder) + requestBuilder.error(placeholder) + requestBuilder.target(target) + + imageLoader.enqueue(requestBuilder.build()) + } + + +} + + +open class SafeTarget( + private val notificationId: Int, + private val onLoaded: (Bitmap) -> Unit +) : Target { + + var isCancelled = false + + override fun onSuccess(result: Drawable) { + super.onSuccess(result) + Log.d("onSuccess", "-") + if (!isCancelled) { + onLoaded((result as BitmapDrawable).bitmap) + } + } + + override fun onStart(placeholder: Drawable?) { + super.onStart(placeholder) + Log.d("onStart", "-") + } + + override fun onError(error: Drawable?) { + super.onError(error) + Log.d("onError", "-") + } + + +} \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/InAppCallManager.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/InAppCallManager.kt new file mode 100644 index 000000000..68e1c1790 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/InAppCallManager.kt @@ -0,0 +1,50 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.content.ComponentName +import android.content.Context +import android.os.Build +import android.telecom.PhoneAccount +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import android.util.Log +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.M) +class InAppCallManager(private val context: Context) { + + companion object { + private const val ACCOUNT_ID = "stream_video_in_app_call_account" + private const val TAG = "InAppCallManager" + } + + fun registerPhoneAccount() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + val componentName = ComponentName(context, IncomingCallConnectionService::class.java) + val handle = PhoneAccountHandle(componentName, ACCOUNT_ID) + + val phoneAccount = PhoneAccount.builder(handle, "Stream Video In-App Call") + .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED) + .build() + + telecomManager.registerPhoneAccount(phoneAccount) + Log.d(TAG, "PhoneAccount registered.") + } + + fun unregisterPhoneAccount() { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + val componentName = ComponentName(context, IncomingCallConnectionService::class.java) + val handle = PhoneAccountHandle(componentName, ACCOUNT_ID) + + telecomManager.unregisterPhoneAccount(handle) + Log.d(TAG, "PhoneAccount unregistered.") + } + + fun getPhoneAccountHandle(): PhoneAccountHandle { + return PhoneAccountHandle( + ComponentName(context, IncomingCallConnectionService::class.java), + ACCOUNT_ID + ) + } +} diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallActivity.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallActivity.kt new file mode 100644 index 000000000..c4c73bfe2 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallActivity.kt @@ -0,0 +1,368 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.app.Activity +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.Window +import android.view.WindowManager +import android.view.animation.AnimationUtils +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import io.getstream.video.flutter.stream_video_push_notification.widgets.RippleRelativeLayout +import de.hdodenhof.circleimageview.CircleImageView +import kotlin.math.abs +import android.view.ViewGroup.MarginLayoutParams +import android.os.PowerManager +import android.text.TextUtils +import android.util.Log + +class IncomingCallActivity : Activity() { + + companion object { + + private const val ACTION_ENDED_CALL_INCOMING = + "io.getstream.video.ACTION_ENDED_CALL_INCOMING" + + fun getIntent(context: Context, data: Bundle) = + Intent(IncomingCallConstants.ACTION_CALL_INCOMING).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_INCOMING}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + fun getIntentEnded(context: Context, isAccepted: Boolean): Intent { + val intent = Intent("${context.packageName}.${ACTION_ENDED_CALL_INCOMING}") + intent.putExtra("ACCEPTED", isAccepted) + return intent + } + } + + inner class EndedIncomingCallBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!isFinishing) { + val isAccepted = intent.getBooleanExtra("ACCEPTED", false) + if (isAccepted) { + finishDelayed() + } else { + finishTask() + } + } + } + } + + private var endedIncomingCallBroadcastReceiver = EndedIncomingCallBroadcastReceiver() + + private lateinit var ivBackground: ImageView + private lateinit var llBackgroundAnimation: RippleRelativeLayout + + private lateinit var tvCallerName: TextView + private lateinit var tvNumber: TextView + private lateinit var ivLogo: ImageView + private lateinit var ivAvatar: CircleImageView + + private lateinit var llAction: LinearLayout + private lateinit var ivAcceptCall: ImageView + private lateinit var tvAccept: TextView + + private lateinit var ivDeclineCall: ImageView + private lateinit var tvDecline: TextView + + private var wakeLock: PowerManager.WakeLock? = null + + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestedOrientation = if (!Utils.isTablet(this@IncomingCallActivity)) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + setTurnScreenOn(true) + setShowWhenLocked(true) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED) + window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD) + } + transparentStatusAndNavigation() + setContentView(R.layout.activity_incoming_call) + initView() + incomingData(intent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver( + endedIncomingCallBroadcastReceiver, + IntentFilter("${packageName}.${ACTION_ENDED_CALL_INCOMING}"), + Context.RECEIVER_EXPORTED, + ) + } else { + registerReceiver( + endedIncomingCallBroadcastReceiver, + IntentFilter("${packageName}.${ACTION_ENDED_CALL_INCOMING}") + ) + } + } + + private fun wakeLockRequest(duration: Long) { + val pm = applicationContext.getSystemService(POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.FULL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP, + "IncomingCall:PowerManager" + ) + wakeLock?.acquire(duration) + } + + private fun transparentStatusAndNavigation() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + setWindowFlag( + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS + or WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, true + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + setWindowFlag( + (WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS + or WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION), false + ) + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.TRANSPARENT + } + } + + private fun setWindowFlag(bits: Int, on: Boolean) { + val win: Window = window + val winParams: WindowManager.LayoutParams = win.attributes + if (on) { + winParams.flags = winParams.flags or bits + } else { + winParams.flags = winParams.flags and bits.inv() + } + win.attributes = winParams + } + + + private fun incomingData(intent: Intent) { + val data = intent.extras?.getBundle(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) + if (data == null) finish() + + val showFullScreenOnLockScreen = + data?.getBoolean(IncomingCallConstants.EXTRA_CALL_IS_SHOW_FULL_LOCKED_SCREEN, true) + if (showFullScreenOnLockScreen == true) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED) + } + } + + val textColor = data?.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_TEXT_COLOR, "#ffffff") + val showCallHandle = data?.getBoolean(IncomingCallConstants.EXTRA_CALL_SHOW_CALL_HANDLE, false) + tvCallerName.text = data?.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, "") + tvNumber.text = data?.getString(IncomingCallConstants.EXTRA_CALL_HANDLE, "") + tvNumber.visibility = if (showCallHandle == true) View.VISIBLE else View.INVISIBLE + + try { + tvCallerName.setTextColor(Color.parseColor(textColor)) + tvNumber.setTextColor(Color.parseColor(textColor)) + } catch (error: Exception) { + } + + val showLogo = data?.getBoolean(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_SHOW_LOGO, false) + ivLogo.visibility = if (showLogo == true) View.VISIBLE else View.INVISIBLE + var logoUrl = data?.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_LOGO_URL, "") + if (!logoUrl.isNullOrEmpty()) { + if (!logoUrl.startsWith("http://", true) && !logoUrl.startsWith("https://", true)) { + logoUrl = String.format("file:///android_asset/flutter_assets/%s", logoUrl) + } + val headers = + data?.getSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS) as HashMap + ImageLoaderProvider.loadImage(this@IncomingCallActivity, logoUrl, headers, R.drawable.transparent, ivLogo) + } + + var defaultAvatar = data?.getString(IncomingCallConstants.EXTRA_CALL_DEFAULT_AVATAR, "") + var avatarUrl = data?.getString(IncomingCallConstants.EXTRA_CALL_AVATAR, "") + + if(avatarUrl.isNullOrEmpty() && !defaultAvatar.isNullOrEmpty()) { + avatarUrl = defaultAvatar + } + + if (!avatarUrl.isNullOrEmpty()) { + ivAvatar.visibility = View.VISIBLE + if (!avatarUrl.startsWith("http://", true) && !avatarUrl.startsWith("https://", true)) { + avatarUrl = String.format("file:///android_asset/flutter_assets/%s", avatarUrl) + } + val headers = + data?.getSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS) as HashMap + ImageLoaderProvider.loadImage(this@IncomingCallActivity, avatarUrl, headers, R.drawable.ic_default_avatar, ivAvatar) + } + + val callType = data?.getInt(IncomingCallConstants.EXTRA_CALL_TYPE, 0) ?: 0 + if (callType > 0) { + ivAcceptCall.setImageResource(R.drawable.ic_video) + } + val duration = data?.getLong(IncomingCallConstants.EXTRA_CALL_DURATION, 0L) ?: 0L + wakeLockRequest(duration) + + finishTimeout(data, duration) + + val textAccept = data?.getString(IncomingCallConstants.EXTRA_CALL_TEXT_ACCEPT, "") + tvAccept.text = + if (TextUtils.isEmpty(textAccept)) getString(R.string.text_accept) else textAccept + val textDecline = data?.getString(IncomingCallConstants.EXTRA_CALL_TEXT_DECLINE, "") + tvDecline.text = + if (TextUtils.isEmpty(textDecline)) getString(R.string.text_decline) else textDecline + + try { + tvAccept.setTextColor(Color.parseColor(textColor)) + tvDecline.setTextColor(Color.parseColor(textColor)) + } catch (error: Exception) { + } + + val backgroundColor = + data?.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_COLOR, "#0955fa") + try { + ivBackground.setBackgroundColor(Color.parseColor(backgroundColor)) + } catch (error: Exception) { + } + var backgroundUrl = data?.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_URL, "") + if (!backgroundUrl.isNullOrEmpty()) { + if (!backgroundUrl.startsWith("http://", true) && !backgroundUrl.startsWith( + "https://", + true + ) + ) { + backgroundUrl = + String.format("file:///android_asset/flutter_assets/%s", backgroundUrl) + } + val headers = + data?.getSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS) as HashMap + ImageLoaderProvider.loadImage(this@IncomingCallActivity, backgroundUrl, headers, R.drawable.transparent, ivBackground) + } + } + + private fun finishTimeout(data: Bundle?, duration: Long) { + val currentSystemTime = System.currentTimeMillis() + val timeStartCall = + data?.getLong(IncomingCallNotificationManager.EXTRA_TIME_START_CALL, currentSystemTime) + ?: currentSystemTime + + val timeOut = duration - abs(currentSystemTime - timeStartCall) + Handler(Looper.getMainLooper()).postDelayed({ + if (!isFinishing) { + finishTask() + } + }, timeOut) + } + + private fun initView() { + ivBackground = findViewById(R.id.ivBackground) + llBackgroundAnimation = findViewById(R.id.llBackgroundAnimation) + llBackgroundAnimation.layoutParams.height = + Utils.getScreenWidth() + Utils.getStatusBarHeight(this@IncomingCallActivity) + llBackgroundAnimation.startRippleAnimation() + + tvCallerName = findViewById(R.id.tvCallerName) + tvNumber = findViewById(R.id.tvNumber) + ivLogo = findViewById(R.id.ivLogo) + ivAvatar = findViewById(R.id.ivAvatar) + + llAction = findViewById(R.id.llAction) + + val params = llAction.layoutParams as MarginLayoutParams + params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this@IncomingCallActivity)) + llAction.layoutParams = params + + ivAcceptCall = findViewById(R.id.ivAcceptCall) + tvAccept = findViewById(R.id.tvAccept) + ivDeclineCall = findViewById(R.id.ivDeclineCall) + tvDecline = findViewById(R.id.tvDecline) + animateAcceptCall() + + ivAcceptCall.setOnClickListener { + onAcceptClick() + } + ivDeclineCall.setOnClickListener { + onDeclineClick() + } + } + + private fun animateAcceptCall() { + val shakeAnimation = + AnimationUtils.loadAnimation(this@IncomingCallActivity, R.anim.shake_anim) + ivAcceptCall.animation = shakeAnimation + } + + private fun onAcceptClick() { + val data = intent.extras?.getBundle(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) + + IncomingCallNotificationService.startServiceWithAction( + this@IncomingCallActivity, + IncomingCallConstants.ACTION_CALL_ACCEPT, + data + ) + + val acceptIntent = + TransparentActivity.getIntent(this, IncomingCallConstants.ACTION_CALL_ACCEPT, data) + startActivity(acceptIntent) + + dismissKeyguard() + finish() + } + + private fun dismissKeyguard() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } + } + + private fun onDeclineClick() { + val data = intent.extras?.getBundle(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) + + val intent = + IncomingCallBroadcastReceiver.getIntentDecline(this@IncomingCallActivity, data) + sendBroadcast(intent) + finishTask() + } + + private fun finishDelayed() { + Handler(Looper.getMainLooper()).postDelayed({ + finishTask() + }, 1000) + } + + private fun finishTask() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask() + } else { + finish() + } + } + + override fun onDestroy() { + wakeLock?.release() + wakeLock = null + unregisterReceiver(endedIncomingCallBroadcastReceiver) + super.onDestroy() + } + + override fun onBackPressed() {} +} diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallBroadcastReceiver.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallBroadcastReceiver.kt new file mode 100644 index 000000000..5d2b9475c --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallBroadcastReceiver.kt @@ -0,0 +1,241 @@ + +package io.getstream.video.flutter.stream_video_push_notification + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log + +class IncomingCallBroadcastReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "IncomingCallReceiver" + var silenceEvents = false + + fun getIntent(context: Context, action: String, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + this.action = "${context.packageName}.${action}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentIncoming(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_INCOMING}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentStart(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_START}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentAccept(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_ACCEPT}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentDecline(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_DECLINE}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentEnded(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_ENDED}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentTimeout(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_TIMEOUT}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentCallback(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_CALLBACK}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentHeldByCell(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_HELD}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentUnHeldByCell(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_UNHELD}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + + fun getIntentConnected(context: Context, data: Bundle?) = + Intent(context, IncomingCallBroadcastReceiver::class.java).apply { + action = "${context.packageName}.${IncomingCallConstants.ACTION_CALL_CONNECTED}" + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + } + + private val incomingCallNotificationManager: IncomingCallNotificationManager? = StreamVideoPushNotificationPlugin.getInstance()?.getIncomingCallNotificationManager() + + + @SuppressLint("MissingPermission") + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action ?: return + val data = intent.extras?.getBundle(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) ?: return + when (action) { + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_INCOMING}" -> { + try { + incomingCallNotificationManager?.showIncomingNotification(data) + sendEventFlutter(IncomingCallConstants.ACTION_CALL_INCOMING, data) + addCall(context, Data.fromBundle(data)) + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_START}" -> { + try { + // start service and show ongoing call when call is accepted + IncomingCallNotificationService.startServiceWithAction( + context, + IncomingCallConstants.ACTION_CALL_START, + data + ) + sendEventFlutter(IncomingCallConstants.ACTION_CALL_START, data) + addCall(context, Data.fromBundle(data), true) + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_ACCEPT}" -> { + try { + // start service and show ongoing call when call is accepted + IncomingCallNotificationService.startServiceWithAction( + context, + IncomingCallConstants.ACTION_CALL_ACCEPT, + data + ) + sendEventFlutter(IncomingCallConstants.ACTION_CALL_ACCEPT, data) + addCall(context, Data.fromBundle(data), true) + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_DECLINE}" -> { + try { + // clear notification + incomingCallNotificationManager?.clearIncomingNotification(data, false) + sendEventFlutter(IncomingCallConstants.ACTION_CALL_DECLINE, data) + removeCall(context, Data.fromBundle(data)) + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_ENDED}" -> { + try { + // clear notification and stop service + incomingCallNotificationManager?.clearIncomingNotification(data, false) + IncomingCallNotificationService.stopService(context) + sendEventFlutter(IncomingCallConstants.ACTION_CALL_ENDED, data) + removeCall(context, Data.fromBundle(data)) + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_TIMEOUT}" -> { + try { + incomingCallNotificationManager?.clearIncomingNotification(data, false) + sendEventFlutter(IncomingCallConstants.ACTION_CALL_TIMEOUT, data) + removeCall(context, Data.fromBundle(data)) + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_CONNECTED}" -> { + try { + sendEventFlutter(IncomingCallConstants.ACTION_CALL_CONNECTED, data) + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + + "${context.packageName}.${IncomingCallConstants.ACTION_CALL_CALLBACK}" -> { + try { + incomingCallNotificationManager?.clearMissCallNotification(data) + sendEventFlutter(IncomingCallConstants.ACTION_CALL_CALLBACK, data) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + val closeNotificationPanel = Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS) + context.sendBroadcast(closeNotificationPanel) + } + } catch (error: Exception) { + Log.e(TAG, null, error) + } + } + } + } + + private fun sendEventFlutter(event: String, data: Bundle) { + if (silenceEvents) return + + val missedCallNotification = mapOf( + "id" to data.getInt(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID), + "showNotification" to data.getBoolean(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SHOW), + "count" to data.getInt(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_COUNT), + "subtitle" to data.getString(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SUBTITLE), + "callbackText" to data.getString(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_TEXT), + "showCallbackButton" to data.getBoolean(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_SHOW), + ) + + val incomingCallNotification = mapOf( + "fullScreenShowLogo" to data.getBoolean(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_SHOW_LOGO, false), + "fullScreenLogoUrl" to data.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_LOGO_URL, ""), + "fullScreenBackgroundColor" to data.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_COLOR, ""), + "fullScreenBackgroundUrl" to data.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_BACKGROUND_URL, ""), + "fullScreenTextColor" to data.getString(IncomingCallConstants.EXTRA_CALL_FULL_SCREEN_TEXT_COLOR, ""), + "textAccept" to data.getString(IncomingCallConstants.EXTRA_CALL_TEXT_ACCEPT, ""), + "textDecline" to data.getString(IncomingCallConstants.EXTRA_CALL_TEXT_DECLINE, ""), + ) + + val android = mapOf( + "ringtonePath" to data.getString(IncomingCallConstants.EXTRA_CALL_RINGTONE_PATH, ""), + "defaultAvatar" to data.getString(IncomingCallConstants.EXTRA_CALL_DEFAULT_AVATAR, ""), + "missedCallNotification" to missedCallNotification, + "incomingCallNotification" to incomingCallNotification, + "incomingCallNotificationChannelName" to data.getString( + IncomingCallConstants.EXTRA_CALL_INCOMING_CALL_NOTIFICATION_CHANNEL_NAME, + "" + ), + "missedCallNotificationChannelName" to data.getString( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_NOTIFICATION_CHANNEL_NAME, + "" + ), + "isImportant" to data.getBoolean(IncomingCallConstants.EXTRA_CALL_IS_IMPORTANT, true), + "isBot" to data.getBoolean(IncomingCallConstants.EXTRA_CALL_IS_BOT, false), + ) + + val forwardData = mapOf( + "id" to data.getString(IncomingCallConstants.EXTRA_CALL_ID, ""), + "callerName" to data.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, ""), + "avatar" to data.getString(IncomingCallConstants.EXTRA_CALL_AVATAR, ""), + "number" to data.getString(IncomingCallConstants.EXTRA_CALL_HANDLE, ""), + "type" to data.getInt(IncomingCallConstants.EXTRA_CALL_TYPE, 0), + "duration" to data.getLong(IncomingCallConstants.EXTRA_CALL_DURATION, 0L), + "extra" to data.getSerializable(IncomingCallConstants.EXTRA_CALL_EXTRA), + "android" to android + ) + + StreamVideoPushNotificationPlugin.sendEvent(event, forwardData) + } +} \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConnectionService.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConnectionService.kt new file mode 100644 index 000000000..675d525be --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConnectionService.kt @@ -0,0 +1,10 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.os.Build +import android.telecom.ConnectionService +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.M) +class IncomingCallConnectionService: ConnectionService() { + //not need overridesss +} \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConstants.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConstants.kt new file mode 100644 index 000000000..1e8af180c --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConstants.kt @@ -0,0 +1,74 @@ +package io.getstream.video.flutter.stream_video_push_notification + +object IncomingCallConstants { + const val ACTION_CALL_INCOMING = + "io.getstream.video.ACTION_CALL_INCOMING" + const val ACTION_CALL_START = "io.getstream.video.ACTION_CALL_START" + const val ACTION_CALL_ACCEPT = + "io.getstream.video.ACTION_CALL_ACCEPT" + const val ACTION_CALL_DECLINE = + "io.getstream.video.ACTION_CALL_DECLINE" + const val ACTION_CALL_ENDED = + "io.getstream.video.ACTION_CALL_ENDED" + const val ACTION_CALL_TOGGLE_MUTE = + "io.getstream.video.ACTION_CALL_TOGGLE_MUTE" + const val ACTION_CALL_TOGGLE_HOLD = + "io.getstream.video.ACTION_CALL_TOGGLE_HOLD" + const val ACTION_CALL_TIMEOUT = + "io.getstream.video.ACTION_CALL_TIMEOUT" + const val ACTION_CALL_CALLBACK = + "io.getstream.video.ACTION_CALL_CALLBACK" + const val ACTION_CALL_CUSTOM = + "io.getstream.video.ACTION_CALL_CUSTOM" + const val ACTION_CALL_AUDIO_STATE_CHANGE = + "io.getstream.video.ACTION_CALL_AUDIO_STATE_CHANGE" + const val ACTION_CALL_HELD = "io.getstream.video.ACTION_CALL_HELD" + const val ACTION_CALL_UNHELD = "io.getstream.video.ACTION_CALL_UNHELD" + const val ACTION_CALL_CONNECTED = "io.getstream.video.ACTION_CALL_CONNECTED" + + + const val EXTRA_CALL_INCOMING_DATA = "EXTRA_CALL_INCOMING_DATA" + + const val EXTRA_CALL_ID = "EXTRA_CALL_ID" + const val EXTRA_CALL_NAME_CALLER = "EXTRA_CALL_NAME_CALLER" + const val EXTRA_CALL_APP_NAME = "EXTRA_CALL_APP_NAME" + const val EXTRA_CALL_HANDLE = "EXTRA_CALL_HANDLE" + const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE" + const val EXTRA_CALL_AVATAR = "EXTRA_CALL_AVATAR" + const val EXTRA_CALL_DEFAULT_AVATAR = "EXTRA_CALL_DEFAULT_AVATAR" + const val EXTRA_CALL_DURATION = "EXTRA_CALL_DURATION" + const val EXTRA_CALL_TEXT_ACCEPT = "EXTRA_CALL_TEXT_ACCEPT" + const val EXTRA_CALL_TEXT_DECLINE = "EXTRA_CALL_TEXT_DECLINE" + + const val EXTRA_CALL_MISSED_CALL_ID = "EXTRA_CALL_MISSED_CALL_ID" + const val EXTRA_CALL_MISSED_CALL_SHOW = "EXTRA_CALL_MISSED_CALL_SHOW" + const val EXTRA_CALL_MISSED_CALL_COUNT = "EXTRA_CALL_MISSED_CALL_COUNT" + const val EXTRA_CALL_MISSED_CALL_SUBTITLE = "EXTRA_CALL_MISSED_CALL_SUBTITLE" + const val EXTRA_CALL_MISSED_CALL_CALLBACK_SHOW = "EXTRA_CALL_MISSED_CALL_CALLBACK_SHOW" + const val EXTRA_CALL_MISSED_CALL_CALLBACK_TEXT = + "EXTRA_CALL_MISSED_CALL_CALLBACK_TEXT" + + const val EXTRA_CALL_EXTRA = "EXTRA_CALL_EXTRA" + const val EXTRA_CALL_HEADERS = "EXTRA_CALL_HEADERS" + const val EXTRA_CALL_SHOW_CALL_HANDLE = "EXTRA_CALL_SHOW_CALL_HANDLE" + const val EXTRA_CALL_RINGTONE_PATH = "EXTRA_CALL_RINGTONE_PATH" + + const val EXTRA_CALL_FULL_SCREEN_SHOW_LOGO = "EXTRA_CALL_FULL_SCREEN_SHOW_LOGO" + const val EXTRA_CALL_FULL_SCREEN_LOGO_URL = "EXTRA_CALL_FULL_SCREEN_LOGO_URL" + const val EXTRA_CALL_FULL_SCREEN_BACKGROUND_COLOR = "EXTRA_CALL_FULL_SCREEN_BACKGROUND_COLOR" + const val EXTRA_CALL_FULL_SCREEN_BACKGROUND_URL = "EXTRA_CALL_FULL_SCREEN_BACKGROUND_URL" + const val EXTRA_CALL_FULL_SCREEN_TEXT_COLOR = "EXTRA_CALL_FULL_SCREEN_TEXT_COLOR" + + const val EXTRA_CALL_INCOMING_CALL_NOTIFICATION_CHANNEL_NAME = + "EXTRA_CALL_INCOMING_CALL_NOTIFICATION_CHANNEL_NAME" + const val EXTRA_CALL_MISSED_CALL_NOTIFICATION_CHANNEL_NAME = + "EXTRA_CALL_MISSED_CALL_NOTIFICATION_CHANNEL_NAME" + const val EXTRA_CALL_ONGOING_CALL_NOTIFICATION_CHANNEL_NAME = + "EXTRA_CALL_ONGOING_CALL_NOTIFICATION_CHANNEL_NAME" + + const val EXTRA_CALL_ACTION_FROM = "EXTRA_CALL_ACTION_FROM" + + const val EXTRA_CALL_IS_SHOW_FULL_LOCKED_SCREEN = "EXTRA_CALL_IS_SHOW_FULL_LOCKED_SCREEN" + const val EXTRA_CALL_IS_IMPORTANT = "EXTRA_CALL_IS_IMPORTANT" + const val EXTRA_CALL_IS_BOT = "EXTRA_CALL_IS_BOT" +} diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationManager.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationManager.kt new file mode 100644 index 000000000..c1e63d1d2 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationManager.kt @@ -0,0 +1,748 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.text.TextUtils +import android.text.format.DateFormat +import android.view.View +import android.widget.RemoteViews +import android.util.Log +import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import java.util.Date + + +class IncomingCallNotificationManager( + private val context: Context, + private val incomingCallSoundPlayerManager: IncomingCallSoundPlayerManager? +) { + + companion object { + const val PERMISSION_NOTIFICATION_REQUEST_CODE = 6969 + + const val EXTRA_TIME_START_CALL = "EXTRA_TIME_START_CALL" + + const val NOTIFICATION_CHANNEL_ID_INCOMING = "io.getstream.video_incoming_call_channel_id" + const val NOTIFICATION_CHANNEL_ID_MISSED = "io.getstream.video_missed_call_channel_id" + const val MISSED_GROUP_KEY = "io.getstream.video.flutter.missed_calls" + } + + private var dataNotificationPermission: Map = HashMap() + + private var notificationBuilder: NotificationCompat.Builder? = null + private var notificationViews: RemoteViews? = null + private var notificationSmallViews: RemoteViews? = null + + private var notificationMissingBuilder: NotificationCompat.Builder? = null + private var notificationMissingViews: RemoteViews? = null + private var notificationMissingSmallViews: RemoteViews? = null + + private var targetInComingAvatarDefault: SafeTarget? = null + private var targetInComingAvatarCustom: SafeTarget? = null + + private var targetMissingAvatarDefault: SafeTarget? = null + private var targetMissingAvatarCustom: SafeTarget? = null + + @SuppressLint("MissingPermission") + private fun createInComingAvatarTargetCustom( + notificationId: Int, + isCallStyle: Boolean = false + ): SafeTarget { + return object : SafeTarget(notificationId, onLoaded = { bitmap -> + notificationViews?.setImageViewBitmap(R.id.ivAvatar, bitmap) + notificationViews?.setViewVisibility(R.id.ivAvatar, View.VISIBLE) + notificationSmallViews?.setImageViewBitmap(R.id.ivAvatar, bitmap) + notificationSmallViews?.setViewVisibility(R.id.ivAvatar, View.VISIBLE) + if (isCallStyle) notificationBuilder?.setLargeIcon(bitmap) + notificationBuilder?.let { getNotificationManager().notify(notificationId, it.build()) } + }) {} + } + + @SuppressLint("MissingPermission") + private fun createMissingAvatarTargetCustom( + notificationId: Int, + builder: NotificationCompat.Builder, + bigViews: RemoteViews?, + smallViews: RemoteViews? + ): SafeTarget { + return object : SafeTarget(notificationId, onLoaded = { bitmap -> + bigViews?.setImageViewBitmap(R.id.ivAvatar, bitmap) + bigViews?.setViewVisibility(R.id.ivAvatar, View.VISIBLE) + smallViews?.setImageViewBitmap(R.id.ivAvatar, bitmap) + smallViews?.setViewVisibility(R.id.ivAvatar, View.VISIBLE) + getNotificationManager().notify(notificationId, builder.build()) + }) {} + } + + @SuppressLint("MissingPermission") + fun getIncomingNotification(data: Bundle): IncomingCallNotification? { + data.putLong(EXTRA_TIME_START_CALL, System.currentTimeMillis()) + + val notificationId = + data.getString(IncomingCallConstants.EXTRA_CALL_ID, "stream_video_call").hashCode() + + ensureNotificationChannelsCreated(data) + + notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID_INCOMING) + notificationBuilder?.setChannelId(NOTIFICATION_CHANNEL_ID_INCOMING) + notificationBuilder?.setDefaults(NotificationCompat.DEFAULT_VIBRATE) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + notificationBuilder?.setCategory(NotificationCompat.CATEGORY_CALL) + notificationBuilder?.priority = NotificationCompat.PRIORITY_MAX + } + + notificationBuilder?.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + notificationBuilder?.setOngoing(true) + notificationBuilder?.setAutoCancel(false) + notificationBuilder?.setWhen(System.currentTimeMillis()) + notificationBuilder?.setTimeoutAfter( + data.getLong( + IncomingCallConstants.EXTRA_CALL_DURATION, 0L + ) + ) + notificationBuilder?.setOnlyAlertOnce(true) + notificationBuilder?.setSound(null) + notificationBuilder?.setFullScreenIntent( + getActivityPendingIntent(notificationId, data), true + ) + notificationBuilder?.setContentIntent(getActivityPendingIntent(notificationId, data)) + + val typeCall = data.getInt(IncomingCallConstants.EXTRA_CALL_TYPE, -1) + var smallIcon = context.applicationInfo.icon + + if (typeCall > 0) { + smallIcon = R.drawable.ic_video + } else { + if (smallIcon >= 0) { + smallIcon = R.drawable.ic_accept + } + } + + notificationBuilder?.setSmallIcon(smallIcon) + notificationBuilder?.setChannelId(NOTIFICATION_CHANNEL_ID_INCOMING) + notificationBuilder?.priority = NotificationCompat.PRIORITY_MAX + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val caller = data.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, "") + val person = Person.Builder().setName(caller).setImportant( + data.getBoolean(IncomingCallConstants.EXTRA_CALL_IS_IMPORTANT, true) + ).setBot(data.getBoolean(IncomingCallConstants.EXTRA_CALL_IS_BOT, false)).build() + + notificationBuilder?.setStyle( + NotificationCompat.CallStyle.forIncomingCall( + person, + getDeclinePendingIntent(notificationId, data), + getAcceptPendingIntent(notificationId, data), + ).setIsVideo(typeCall > 0) + ) + + val showCallHandle = + data.getBoolean(IncomingCallConstants.EXTRA_CALL_SHOW_CALL_HANDLE, false) + if (showCallHandle) { + notificationBuilder?.setContentText( + data.getString( + IncomingCallConstants.EXTRA_CALL_HANDLE, "" + ) + ) + } + + var defaultAvatar = data.getString( + IncomingCallConstants.EXTRA_CALL_DEFAULT_AVATAR, "" + ) + var avatarUrl = data.getString(IncomingCallConstants.EXTRA_CALL_AVATAR, "") + + if(avatarUrl.isNullOrEmpty() && !defaultAvatar.isNullOrEmpty()) { + avatarUrl = defaultAvatar + } + + if (!avatarUrl.isNullOrEmpty()) { + if (!avatarUrl.startsWith("http://", true) && !avatarUrl.startsWith( + "https://", + true + ) + ) { + avatarUrl = + String.format("file:///android_asset/flutter_assets/%s", avatarUrl) + } + val headers = + data.getSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS) as HashMap + if (targetInComingAvatarCustom == null) targetInComingAvatarCustom = + createInComingAvatarTargetCustom(notificationId, true) + + ImageLoaderProvider.loadImage( + context, + avatarUrl, + headers, + targetInComingAvatarCustom + ) + + } + } else { + notificationViews = + RemoteViews(context.packageName, R.layout.layout_custom_notification) + initInComingNotificationViews(notificationId, notificationViews!!, data) + + if ((Build.MANUFACTURER.equals( + "Samsung", ignoreCase = true + ) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + ) { + notificationSmallViews = RemoteViews( + context.packageName, R.layout.layout_custom_small_ex_notification + ) + initInComingNotificationViews(notificationId, notificationSmallViews!!, data) + } else { + notificationSmallViews = + RemoteViews(context.packageName, R.layout.layout_custom_small_notification) + initInComingNotificationViews(notificationId, notificationSmallViews!!, data) + } + + notificationBuilder?.setStyle(NotificationCompat.DecoratedCustomViewStyle()) + notificationBuilder?.setCustomContentView(notificationSmallViews) + notificationBuilder?.setCustomBigContentView(notificationViews) + notificationBuilder?.setCustomHeadsUpContentView(notificationSmallViews) + } + + notificationBuilder?.setOngoing(true) + val notification = notificationBuilder?.build() + + return notification?.let { IncomingCallNotification(notificationId, it) } + } + + private fun initInComingNotificationViews( + notificationId: Int, remoteViews: RemoteViews, data: Bundle + ) { + remoteViews.setTextViewText( + R.id.tvCallerName, data.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, "") + ) + val showCallHandle = data.getBoolean(IncomingCallConstants.EXTRA_CALL_SHOW_CALL_HANDLE, false) + if (showCallHandle) { + remoteViews.setTextViewText( + R.id.tvNumber, data.getString(IncomingCallConstants.EXTRA_CALL_HANDLE, "") + ) + } + remoteViews.setOnClickPendingIntent( + R.id.llDecline, getDeclinePendingIntent(notificationId, data) + ) + val textDecline = data.getString(IncomingCallConstants.EXTRA_CALL_TEXT_DECLINE, "") + remoteViews.setTextViewText( + R.id.tvDecline, + if (TextUtils.isEmpty(textDecline)) context.getString(R.string.text_decline) else textDecline + ) + remoteViews.setOnClickPendingIntent( + R.id.llAccept, getAcceptPendingIntent(notificationId, data) + ) + val textAccept = data.getString(IncomingCallConstants.EXTRA_CALL_TEXT_ACCEPT, "") + remoteViews.setTextViewText( + R.id.tvAccept, + if (TextUtils.isEmpty(textAccept)) context.getString(R.string.text_accept) else textAccept + ) + + var defaultAvatar = data.getString( + IncomingCallConstants.EXTRA_CALL_DEFAULT_AVATAR, "" + ) + var avatarUrl = data.getString(IncomingCallConstants.EXTRA_CALL_AVATAR, "") + + if(avatarUrl.isNullOrEmpty() && !defaultAvatar.isNullOrEmpty()) { + avatarUrl = defaultAvatar + } + + if (!avatarUrl.isNullOrEmpty()) { + if (!avatarUrl.startsWith("http://", true) && !avatarUrl.startsWith("https://", true)) { + avatarUrl = String.format("file:///android_asset/flutter_assets/%s", avatarUrl) + } + val headers = + data.getSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS) as HashMap + + if (targetInComingAvatarCustom == null) targetInComingAvatarCustom = + createInComingAvatarTargetCustom(notificationId, false) + ImageLoaderProvider.loadImage(context, avatarUrl, headers, targetInComingAvatarCustom) + } + } + + private fun getSystemFormattedTime(context: Context): String { + val currentTimeMillis = System.currentTimeMillis() + val date = Date(currentTimeMillis) + + val timeFormatter = DateFormat.getTimeFormat(context) + return timeFormatter.format(date) + } + + @SuppressLint("MissingPermission") + fun showMissCallNotification(data: Bundle) { + val isMissedCallShow = + data.getBoolean(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SHOW, true) + if (!isMissedCallShow) return + + // Use explicit integer ID if provided; otherwise derive a stable hash from call ID + val hasExplicitMissedId = data.containsKey(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID) + val missedNotificationId = if (hasExplicitMissedId) { + data.getInt(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID).hashCode() + } else { + val fallbackId = data.getString( + IncomingCallConstants.EXTRA_CALL_ID, + "stream_video_call" + ) + ("missing_$fallbackId").hashCode() + } + + ensureNotificationChannelsCreated(data); + + val missedCallSound: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val typeCall = data.getInt(IncomingCallConstants.EXTRA_CALL_TYPE, -1) + + var smallIcon = context.applicationInfo.icon + if (typeCall > 0) { + smallIcon = R.drawable.ic_video_missed + } else { + if (smallIcon >= 0) { + smallIcon = R.drawable.ic_call_missed + } + } + + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID_MISSED) + builder.setChannelId(NOTIFICATION_CHANNEL_ID_MISSED) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setCategory(Notification.CATEGORY_MISSED_CALL) + } + } + + builder.setWhen(System.currentTimeMillis()) + + val textMissedCall = data.getString(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_SUBTITLE, "") + builder.setSubText( + if (TextUtils.isEmpty(textMissedCall)) context.getString( + R.string.text_missed_call + ) else textMissedCall + ) + + builder.setSmallIcon(smallIcon) + builder.setOnlyAlertOnce(true) + + notificationMissingViews = + RemoteViews(context.packageName, R.layout.layout_custom_miss_notification) + notificationMissingSmallViews = + RemoteViews(context.packageName, R.layout.layout_custom_miss_small_notification) + + notificationMissingViews?.setTextViewText( + R.id.tvCallerName, data.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, "") + ) + notificationMissingSmallViews?.setTextViewText( + R.id.tvCallerName, data.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, "") + ) + + notificationMissingSmallViews?.setTextViewText( + R.id.tvTime, getSystemFormattedTime(context) + ) + val showCallHandle = + data.getBoolean(IncomingCallConstants.EXTRA_CALL_SHOW_CALL_HANDLE, false) + if (showCallHandle) { + notificationMissingViews?.setTextViewText( + R.id.tvNumber, data.getString(IncomingCallConstants.EXTRA_CALL_HANDLE, "") + ) + notificationMissingSmallViews?.setTextViewText( + R.id.tvNumber, data.getString(IncomingCallConstants.EXTRA_CALL_HANDLE, "") + ) + } + + notificationMissingViews?.setOnClickPendingIntent( + R.id.llCallback, getCallbackPendingIntent(missedNotificationId, data) + ) + + val showCallbackButton = data.getBoolean( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_SHOW, true + ) + notificationMissingViews?.setViewVisibility( + R.id.llCallback, if (showCallbackButton) View.VISIBLE else View.GONE + ) + val textCallback = + data.getString(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_CALLBACK_TEXT, "") + notificationMissingViews?.setTextViewText( + R.id.tvCallback, + if (TextUtils.isEmpty(textCallback)) context.getString(R.string.text_call_back) else textCallback + ) + + var defaultAvatar = data.getString( + IncomingCallConstants.EXTRA_CALL_DEFAULT_AVATAR, "" + ) + var avatarUrl = data.getString(IncomingCallConstants.EXTRA_CALL_AVATAR, "") + + if(avatarUrl.isNullOrEmpty() && !defaultAvatar.isNullOrEmpty()) { + avatarUrl = defaultAvatar + } + + if (!avatarUrl.isNullOrEmpty()) { + if (!avatarUrl.startsWith("http://", true) && !avatarUrl.startsWith( + "https://", + true + ) + ) { + avatarUrl = String.format("file:///android_asset/flutter_assets/%s", avatarUrl) + } + val headers = + data.getSerializable(IncomingCallConstants.EXTRA_CALL_HEADERS) as HashMap + + targetMissingAvatarCustom = + createMissingAvatarTargetCustom( + missedNotificationId, + builder, + notificationMissingViews, + notificationMissingSmallViews + ) + ImageLoaderProvider.loadImage( + context, + avatarUrl, + headers, + targetMissingAvatarCustom + ) + } + + // Ensure collapsed (system) view also shows content when the custom view isn't used + val callerName = data.getString(IncomingCallConstants.EXTRA_CALL_NAME_CALLER, "") + builder.setContentTitle(callerName) + builder.setContentText( + if (TextUtils.isEmpty(textMissedCall)) context.getString(R.string.text_missed_call) else textMissedCall + ) + builder.setStyle(NotificationCompat.DecoratedCustomViewStyle()) + builder.setCustomContentView(notificationMissingSmallViews) + builder.setCustomBigContentView(notificationMissingViews) + builder.setGroup(MISSED_GROUP_KEY) + + builder.priority = NotificationCompat.PRIORITY_HIGH + + builder.setSound(missedCallSound) + builder.setContentIntent( + getAppPendingIntent( + missedNotificationId, data + ) + ) + + val notification = builder.build() + if (notification != null) { + getNotificationManager().notify(missedNotificationId, notification) + } + } + + fun clearIncomingNotification(data: Bundle, isAccepted: Boolean) { + incomingCallSoundPlayerManager?.stop() + + context.sendBroadcast(IncomingCallActivity.getIntentEnded(context, isAccepted)) + + val notificationId = + data.getString(IncomingCallConstants.EXTRA_CALL_ID, "stream_video_call").hashCode() + + getNotificationManager().cancel(notificationId) + targetInComingAvatarDefault?.let { + targetInComingAvatarDefault?.isCancelled = true + targetInComingAvatarDefault = null + } + targetInComingAvatarCustom?.let { + targetInComingAvatarCustom?.isCancelled = true + targetInComingAvatarCustom = null + } + } + + fun clearMissCallNotification(data: Bundle) { + // Support both int and string ID styles + val hasExplicitMissedId = data.containsKey(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID) + val missedNotificationId = if (hasExplicitMissedId) { + data.getInt(IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID) + } else { + val missingId = data.getString( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_ID, + data.getString(IncomingCallConstants.EXTRA_CALL_ID, "stream_video_call") + ) + ("missing_$missingId").hashCode() + } + + getNotificationManager().cancel(missedNotificationId) + + targetMissingAvatarDefault?.let { + targetMissingAvatarDefault?.isCancelled = true + targetMissingAvatarDefault = null + } + targetMissingAvatarCustom?.let { + targetMissingAvatarCustom?.isCancelled = true + targetMissingAvatarCustom = null + } + } + + private fun incomingChannelEnabled(): Boolean = getNotificationManager().run { + if (!areNotificationsEnabled()) return false + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = getNotificationChannel(NOTIFICATION_CHANNEL_ID_INCOMING) + return channel != null && channel.importance > NotificationManagerCompat.IMPORTANCE_NONE + } + + return true + } + + fun ensureNotificationChannelsCreated(data: Bundle) { + val incomingCallChannelName = data.getString( + IncomingCallConstants.EXTRA_CALL_INCOMING_CALL_NOTIFICATION_CHANNEL_NAME, "Incoming Call" + ) + val missedCallChannelName = data.getString( + IncomingCallConstants.EXTRA_CALL_MISSED_CALL_NOTIFICATION_CHANNEL_NAME, "Missed Call" + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + getNotificationManager().apply { + var channelCall = getNotificationChannel(NOTIFICATION_CHANNEL_ID_INCOMING) + if (channelCall != null) { + channelCall.setSound(null, null) + } else { + channelCall = NotificationChannel( + NOTIFICATION_CHANNEL_ID_INCOMING, + incomingCallChannelName, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "" + vibrationPattern = longArrayOf(0, 1000, 500, 1000, 500) + lightColor = Color.RED + enableLights(true) + enableVibration(true) + setSound(null, null) + } + } + + channelCall.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + channelCall.importance = NotificationManager.IMPORTANCE_HIGH + + createNotificationChannel(channelCall) + + var channelMissedCall = getNotificationChannel(NOTIFICATION_CHANNEL_ID_MISSED) + if (channelMissedCall == null) { + channelMissedCall = NotificationChannel( + NOTIFICATION_CHANNEL_ID_MISSED, + missedCallChannelName, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "" + vibrationPattern = longArrayOf(0, 1000) + lightColor = Color.RED + enableLights(true) + enableVibration(true) + } + } + + channelMissedCall.importance = NotificationManager.IMPORTANCE_HIGH + createNotificationChannel(channelMissedCall) + } + } + } + + private fun getAcceptPendingIntent(id: Int, data: Bundle): PendingIntent { + val intentTransparent = TransparentActivity.getIntent( + context, IncomingCallConstants.ACTION_CALL_ACCEPT, data + ) + return PendingIntent.getActivity(context, id, intentTransparent, getFlagPendingIntent()) + } + + private fun getDeclinePendingIntent(id: Int, data: Bundle): PendingIntent { + val declineIntent = IncomingCallBroadcastReceiver.getIntentDecline(context, data) + return PendingIntent.getBroadcast(context, id, declineIntent, getFlagPendingIntent()) + } + + private fun getTimeOutPendingIntent(id: Int, data: Bundle): PendingIntent { + val timeOutIntent = IncomingCallBroadcastReceiver.getIntentTimeout(context, data) + return PendingIntent.getBroadcast(context, id, timeOutIntent, getFlagPendingIntent()) + } + + private fun getCallbackPendingIntent(id: Int, data: Bundle): PendingIntent { + val intentTransparent = TransparentActivity.getIntent( + context, IncomingCallConstants.ACTION_CALL_CALLBACK, data + ) + return PendingIntent.getActivity(context, id, intentTransparent, getFlagPendingIntent()) + } + + private fun getActivityPendingIntent(id: Int, data: Bundle): PendingIntent { + val intent = IncomingCallActivity.getIntent(context, data) + return PendingIntent.getActivity(context, id, intent, getFlagPendingIntent()) + } + + private fun getAppPendingIntent(id: Int, data: Bundle): PendingIntent { + val intent: Intent? = AppUtils.getAppIntent(context, data = data) + return PendingIntent.getActivity(context, id, intent, getFlagPendingIntent()) + } + + + private fun getHangupPendingIntent(notificationId: Int, data: Bundle): PendingIntent { + val endedIntent = IncomingCallBroadcastReceiver.getIntentEnded(context, data) + return PendingIntent.getBroadcast( + context, notificationId, endedIntent, getFlagPendingIntent() + ) + } + + private fun getFlagPendingIntent(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + } + + private fun getNotificationManager(): NotificationManagerCompat { + return NotificationManagerCompat.from(context) + } + + @SuppressLint("MissingPermission") + fun showIncomingNotification(data: Bundle) { + val incomingCallNotification = getIncomingNotification(data) + if (incomingChannelEnabled()) { + incomingCallSoundPlayerManager?.play(data) + } + incomingCallNotification?.let { + getNotificationManager().notify( + it.id, incomingCallNotification.notification + ) + } + } + + fun requestNotificationPermission(activity: Activity?, map: Map) { + this.dataNotificationPermission = map + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.let { + if (ActivityCompat.checkSelfPermission( + it, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + it, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + PERMISSION_NOTIFICATION_REQUEST_CODE + ) + } + } + } + } + + fun requestFullIntentPermission(activity: Activity?) { + val canUseFullScreenIntent = getNotificationManager().canUseFullScreenIntent() + if (!canUseFullScreenIntent && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val intent = Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply { + data = Uri.fromParts("package", activity?.packageName, null) + } + activity?.startActivity(intent) + } + } + + fun canUseFullScreenIntent(): Boolean { + val canUseFullScreenIntent = getNotificationManager().canUseFullScreenIntent() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return canUseFullScreenIntent + } + return true + } + + + fun onRequestPermissionsResult(activity: Activity?, requestCode: Int, grantResults: IntArray) { + when (requestCode) { + PERMISSION_NOTIFICATION_REQUEST_CODE -> { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // allow + } else { + //deny + activity?.let { + if (ActivityCompat.shouldShowRequestPermissionRationale( + it, Manifest.permission.POST_NOTIFICATIONS + ) + ) { + //showDialogPermissionRationale() + if (this.dataNotificationPermission["title"] != null && this.dataNotificationPermission["rationaleMessagePermission"] != null) { + showDialogMessage( + it, + this.dataNotificationPermission["title"] as String, + this.dataNotificationPermission["rationaleMessagePermission"] as String + ) { dialog, _ -> + dialog?.dismiss() + requestNotificationPermission( + activity, this.dataNotificationPermission + ) + } + } else { + requestNotificationPermission( + activity, this.dataNotificationPermission + ) + } + } else { + //Open Setting + if (this.dataNotificationPermission["title"] != null && this.dataNotificationPermission["postNotificationMessageRequired"] != null) { + showDialogMessage( + it, + this.dataNotificationPermission["title"] as String, + this.dataNotificationPermission["postNotificationMessageRequired"] as String + ) { dialog, _ -> + dialog?.dismiss() + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", it.packageName, null) + ) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + it.startActivity(intent) + } + } else { + showDialogMessage( + it, + it.resources.getString(R.string.text_title_post_notification), + it.resources.getString(R.string.text_post_notification_message_required) + ) { dialog, _ -> + dialog?.dismiss() + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", it.packageName, null) + ) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + it.startActivity(intent) + } + } + } + } + } + } + } + } + + private fun showDialogMessage( + activity: Activity?, + title: String, + message: String, + okListener: DialogInterface.OnClickListener + ) { + activity?.let { + AlertDialog.Builder(it, R.style.DialogTheme).setTitle(title).setMessage(message) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, null).create().show() + } + } + + fun destroy() { + + incomingCallSoundPlayerManager?.destroy() + } + +} + +data class IncomingCallNotification(val id: Int, val notification: Notification) + + diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationService.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationService.kt new file mode 100644 index 000000000..36fe4a86d --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationService.kt @@ -0,0 +1,91 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.annotation.SuppressLint +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import androidx.core.content.ContextCompat + +class IncomingCallNotificationService : Service() { + + companion object { + + private val ActionForeground = listOf( + IncomingCallConstants.ACTION_CALL_START, + IncomingCallConstants.ACTION_CALL_ACCEPT + ) + + fun startServiceWithAction(context: Context, action: String, data: Bundle?) { + val intent = Intent(context, IncomingCallNotificationService::class.java).apply { + this.action = action + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && intent.action in ActionForeground) { + data?.let { + context.startService(intent) + } + } else { + context.startService(intent) + } + } + + fun stopService(context: Context) { + val intent = Intent(context, IncomingCallNotificationService::class.java) + context.stopService(intent) + } + + } + + private val incomingCallNotificationManager: IncomingCallNotificationManager? = + StreamVideoPushNotificationPlugin.getInstance()?.getIncomingCallNotificationManager() + + + override fun onCreate() { + super.onCreate() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == IncomingCallConstants.ACTION_CALL_START) { + intent.getBundleExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) + ?.let { + stopSelf() + } + } + if (intent?.action == IncomingCallConstants.ACTION_CALL_ACCEPT) { + intent.getBundleExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) + ?.let { + incomingCallNotificationManager?.clearIncomingNotification(it, true) + stopSelf() + } + } + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + incomingCallNotificationManager?.destroy() + } + + override fun onBind(p0: Intent?): IBinder? { + return null + } + + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + }else { + stopForeground(true) + } + stopSelf() + } + + + +} + diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallSoundPlayerManager.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallSoundPlayerManager.kt new file mode 100644 index 000000000..9c4ed2ca2 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallSoundPlayerManager.kt @@ -0,0 +1,214 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.Ringtone +import android.media.RingtoneManager +import android.net.Uri +import android.os.* +import android.text.TextUtils + +class IncomingCallSoundPlayerManager(private val context: Context) { + + private var vibrator: Vibrator? = null + private var audioManager: AudioManager? = null + + private var ringtone: Ringtone? = null + + private var isPlaying: Boolean = false + + + inner class ScreenOffIncomingCallBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + synchronized(this@IncomingCallSoundPlayerManager) { + if (isPlaying) { + stop() + } + } + } + } + + private var screenOffIncomingCallBroadcastReceiver = ScreenOffIncomingCallBroadcastReceiver() + + + fun play(data: Bundle) { + this.isPlaying = true + this.prepare() + this.playSound(data) + this.playVibrator() + + val filter = IntentFilter(Intent.ACTION_SCREEN_OFF) + context.registerReceiver(screenOffIncomingCallBroadcastReceiver, filter) + } + + fun stop() { + this.isPlaying = false + + ringtone?.stop() + vibrator?.cancel() + ringtone = null + vibrator = null + try { + context.unregisterReceiver(screenOffIncomingCallBroadcastReceiver) + }catch (_: Exception){} + } + + fun destroy() { + this.isPlaying = false + + ringtone?.stop() + vibrator?.cancel() + ringtone = null + vibrator = null + try { + context.unregisterReceiver(screenOffIncomingCallBroadcastReceiver) + }catch (_: Exception){} + } + + private fun prepare() { + ringtone?.stop() + vibrator?.cancel() + } + + private fun playVibrator() { + vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = + context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + when (audioManager?.ringerMode) { + AudioManager.RINGER_MODE_SILENT -> { + } + + else -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate( + VibrationEffect.createWaveform( + longArrayOf(0L, 1000L, 1000L), + 0 + ) + ) + } else { + vibrator?.vibrate(longArrayOf(0L, 1000L, 1000L), 0) + } + } + } + } + + private fun playSound(data: Bundle?) { + val sound = data?.getString( + IncomingCallConstants.EXTRA_CALL_RINGTONE_PATH, + "" + ) + val uri = sound?.let { getRingtoneUri(it) } + if (uri == null) { + // Failed to get ringtone url, can't play sound + return + } + try { + ringtone = RingtoneManager.getRingtone(context, uri) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val attributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setLegacyStreamType(AudioManager.STREAM_RING) + .build() + + ringtone?.setAudioAttributes(attributes) + }else { + ringtone?.streamType = AudioManager.STREAM_RING + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ringtone?.isLooping = true + } + ringtone?.play() + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun getRingtoneUri(fileName: String): Uri? { + if (TextUtils.isEmpty(fileName)) { + return getDefaultRingtoneUri() + } + + // If system_ringtone_default is explicitly requested, bypass resource check + if (fileName.equals("system_ringtone_default", true)) { + return getDefaultRingtoneUri(useSystemDefault = true) + } + + try { + val resId = context.resources.getIdentifier(fileName, "raw", context.packageName) + if (resId != 0) { + return Uri.parse("android.resource://${context.packageName}/$resId") + } + + // For any other unresolved filename, return the default ringtone + return getDefaultRingtoneUri() + } catch (e: Exception) { + // If anything fails, try to return the system default ringtone + return getDefaultRingtoneUri() + } + } + + private fun getDefaultRingtoneUri(useSystemDefault: Boolean = false): Uri? { + try { + if (!useSystemDefault) { + // First try to use ringtone_default resource if it exists + val resId = context.resources.getIdentifier("ringtone_default", "raw", context.packageName) + if (resId != 0) { + return Uri.parse("android.resource://${context.packageName}/$resId") + } + } + + // Fall back to system default ringtone + return RingtoneManager.getActualDefaultRingtoneUri( + context, + RingtoneManager.TYPE_RINGTONE + ) + } catch (e: Exception) { + // getActualDefaultRingtoneUri can throw an exception on some devices + // for custom ringtones + return getSafeSystemRingtoneUri() + } + } + + private fun getSafeSystemRingtoneUri(): Uri? { + try { + val defaultUri = RingtoneManager.getActualDefaultRingtoneUri( + context, + RingtoneManager.TYPE_RINGTONE + ) + + val rm = RingtoneManager(context) + rm.setType(RingtoneManager.TYPE_RINGTONE) + val cursor = rm.cursor + if (defaultUri != null && cursor != null) { + while (cursor.moveToNext()) { + val uri = rm.getRingtoneUri(cursor.position) + if (uri == defaultUri) { + cursor.close() + return defaultUri + } + } + } + + // Default isn't system-provided β†’ fallback to first available + if (cursor != null && cursor.moveToFirst()) { + val fallback = rm.getRingtoneUri(cursor.position) + cursor.close() + return fallback + } + } catch (e: Exception) { + e.printStackTrace() + } + return null + } +} \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/SharedPreferencesUtils.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/SharedPreferencesUtils.kt new file mode 100644 index 000000000..142891910 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/SharedPreferencesUtils.kt @@ -0,0 +1,75 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.fasterxml.jackson.core.type.TypeReference + + +private const val INCOMING_CALL_PREFERENCES_FILE_NAME = "stream_video_incoming_call_preferences" +private var prefs: SharedPreferences? = null +private var editor: SharedPreferences.Editor? = null + +private fun initInstance(context: Context) { + prefs = context.getSharedPreferences(INCOMING_CALL_PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + editor = prefs?.edit() +} + + +fun addCall(context: Context?, data: Data, isAccepted: Boolean = false) { + val json = getString(context, "ACTIVE_CALLS", "[]") + val arrayData: ArrayList = Utils.getGsonInstance() + .readValue(json, object : TypeReference>() {}) + val currentData = arrayData.find { it == data } + if(currentData != null) { + currentData.isAccepted = isAccepted + }else { + data.isAccepted = isAccepted + arrayData.add(data) + } + putString(context, "ACTIVE_CALLS", Utils.getGsonInstance().writeValueAsString(arrayData)) +} + +fun removeCall(context: Context?, data: Data) { + val json = getString(context, "ACTIVE_CALLS", "[]") + val arrayData: ArrayList = Utils.getGsonInstance() + .readValue(json, object : TypeReference>() {}) + arrayData.remove(data) + putString(context, "ACTIVE_CALLS", Utils.getGsonInstance().writeValueAsString(arrayData)) +} + +fun removeAllCalls(context: Context?) { + putString(context, "ACTIVE_CALLS", "[]") + remove(context, "ACTIVE_CALLS") +} + +fun getDataActiveCalls(context: Context?): ArrayList { + val json = getString(context, "ACTIVE_CALLS", "[]") + return Utils.getGsonInstance() + .readValue(json, object : TypeReference>() {}) +} + +fun getDataActiveCallsForFlutter(context: Context?): ArrayList> { + val json = getString(context, "ACTIVE_CALLS", "[]") + return Utils.getGsonInstance().readValue(json, object : TypeReference>>() {}) +} + +fun putString(context: Context?, key: String, value: String?) { + if (context == null) return + initInstance(context) + editor?.putString(key, value) + editor?.commit() +} + +fun getString(context: Context?, key: String, defaultValue: String = ""): String? { + if (context == null) return null + initInstance(context) + return prefs?.getString(key, defaultValue) +} + +fun remove(context: Context?, key: String) { + if (context == null) return + initInstance(context) + editor?.remove(key) + editor?.commit() +} diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/StreamVideoPushNotificationPlugin.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/StreamVideoPushNotificationPlugin.kt index 193d5478b..d7dae3d64 100644 --- a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/StreamVideoPushNotificationPlugin.kt +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/StreamVideoPushNotificationPlugin.kt @@ -1,67 +1,417 @@ package io.getstream.video.flutter.stream_video_push_notification +import android.annotation.SuppressLint +import androidx.annotation.NonNull import android.app.Activity import android.app.NotificationManager import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper +import java.lang.ref.WeakReference import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.* import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager -import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin +import io.getstream.video.flutter.stream_video_push_notification.IncomingCallNotificationManager +import io.getstream.video.flutter.stream_video_push_notification.Utils.Companion.reapCollection + +class StreamVideoPushNotificationPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, PluginRegistry.RequestPermissionsResultListener { + companion object { + const val EXTRA_CALL_CALL_DATA = "EXTRA_CALL_CALL_DATA" + + @SuppressLint("StaticFieldLeak") + private lateinit var instance: StreamVideoPushNotificationPlugin + + fun getInstance(): StreamVideoPushNotificationPlugin? { + if (hasInstance()) { + return instance + } + return null + } + + fun hasInstance(): Boolean { + return ::instance.isInitialized + } + + private val methodChannels = mutableMapOf() + private val eventChannels = mutableMapOf() + private val eventHandlers = mutableListOf>() + + fun sendEvent(event: String, body: Map) { + eventHandlers.reapCollection().forEach { + it.get()?.send(event, body) + } + } + + public fun sendEventCustom(event: String, body: Map) { + eventHandlers.reapCollection().forEach { + it.get()?.send(event, body) + } + } + + + fun sharePluginWithRegister(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + initSharedInstance( + flutterPluginBinding.applicationContext, + flutterPluginBinding.binaryMessenger + ) + } + + fun initSharedInstance(context: Context, binaryMessenger: BinaryMessenger) { + if (!::instance.isInitialized) { + instance = StreamVideoPushNotificationPlugin() + instance.incomingCallSoundPlayerManager = IncomingCallSoundPlayerManager(context) + instance.incomingCallNotificationManager = IncomingCallNotificationManager(context, instance.incomingCallSoundPlayerManager) + instance.context = context + } + + val channel = MethodChannel(binaryMessenger, "stream_video_push_notification") + methodChannels[binaryMessenger] = channel + channel.setMethodCallHandler(instance) + + val events = EventChannel(binaryMessenger, "stream_video_push_notification_events") + eventChannels[binaryMessenger] = events + + val handler = EventCallbackHandler() + eventHandlers.add(WeakReference(handler)) + events.setStreamHandler(handler) + + } + + } -/** StreamVideoPushNotificationPlugin */ -class StreamVideoPushNotificationPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { - private lateinit var channel : MethodChannel private var activity: Activity? = null private var context: Context? = null - private lateinit var callkitNotificationManager: CallkitNotificationManager - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext + private var incomingCallNotificationManager: IncomingCallNotificationManager? = null + private var incomingCallSoundPlayerManager: IncomingCallSoundPlayerManager? = null + + fun getIncomingCallNotificationManager(): IncomingCallNotificationManager? { + return incomingCallNotificationManager + } - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "stream_video_push_notification") - channel.setMethodCallHandler(this) + fun getIncomingCallSoundPlayerManager(): IncomingCallSoundPlayerManager? { + return incomingCallSoundPlayerManager + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + sharePluginWithRegister(flutterPluginBinding) - callkitNotificationManager = FlutterCallkitIncomingPlugin.getInstance()?.getCallkitNotificationManager() ?: CallkitNotificationManager(flutterPluginBinding.applicationContext, null) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + InAppCallManager(flutterPluginBinding.applicationContext).registerPhoneAccount() + } } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - if (call.method == "ensureFullScreenIntentPermission") { - val notificationManager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (Build.VERSION.SDK_INT >= 34) { - if (!notificationManager.canUseFullScreenIntent()) { - callkitNotificationManager?.requestFullIntentPermission(activity) + public fun showIncomingNotification(data: Data) { + data.from = "notification" + //send BroadcastReceiver + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentIncoming( + requireNotNull(context), + data.toBundle() + ) + ) + } + + public fun showMissCallNotification(data: Data) { + incomingCallNotificationManager?.showMissCallNotification(data.toBundle()) + } + + public fun startCall(data: Data) { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentStart( + requireNotNull(context), + data.toBundle() + ) + ) + } + + public fun endCall(data: Data) { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentEnded( + requireNotNull(context), + data.toBundle() + ) + ) + } + + public fun endAllCalls() { + val calls = getDataActiveCalls(context) + calls.forEach { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentEnded( + requireNotNull(context), + it.toBundle() + ) + ) } - } - result.success(true) - } else { - result.notImplemented() + removeAllCalls(context) } + + public fun sendEventCustom(body: Map) { + eventHandlers.reapCollection().forEach { + it.get()?.send(IncomingCallConstants.ACTION_CALL_CUSTOM, body) + } + } + + + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + try { + when (call.method) { + "showIncomingCall" -> { + val data = Data(call.arguments() ?: HashMap()) + data.from = "notification" + //send BroadcastReceiver + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentIncoming( + requireNotNull(context), + data.toBundle() + ) + ) + + result.success(true) + } + + "showIncomingCallSilently" -> { + val data = Data(call.arguments() ?: HashMap()) + data.from = "notification" + + result.success(true) + } + + "showMissCallNotification" -> { + val data = Data(call.arguments() ?: HashMap()) + data.from = "notification" + + incomingCallNotificationManager?.clearIncomingNotification(data.toBundle(), false) + incomingCallNotificationManager?.showMissCallNotification(data.toBundle()) + result.success(true) + } + + "startCall" -> { + val data = Data(call.arguments() ?: HashMap()) + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentStart( + requireNotNull(context), + data.toBundle() + ) + ) + + result.success(true) + } + + "muteCall" -> { + val map = buildMap { + val args = call.arguments + if (args is Map<*, *>) { + putAll(args as Map) + } + } + sendEvent(IncomingCallConstants.ACTION_CALL_TOGGLE_MUTE, map) + + result.success(true) + } + + "holdCall" -> { + val map = buildMap { + val args = call.arguments + if (args is Map<*, *>) { + putAll(args as Map) + } + } + sendEvent(IncomingCallConstants.ACTION_CALL_TOGGLE_HOLD, map) + + result.success(true) + } + + "isMuted" -> { + result.success(true) + } + + "endCall" -> { + val calls = getDataActiveCalls(context) + val data = Data(call.arguments() ?: HashMap()) + val currentCall = calls.firstOrNull { it.id == data.id } + if (currentCall != null && context != null) { + if(currentCall.isAccepted) { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentEnded( + requireNotNull(context), + currentCall.toBundle() + ) + ) + }else { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentDecline( + requireNotNull(context), + currentCall.toBundle() + ) + ) + } + } + result.success(true) + } + + "callConnected" -> { + val calls = getDataActiveCalls(context) + val data = Data(call.arguments() ?: HashMap()) + val currentCall = calls.firstOrNull { it.id == data.id } + if (currentCall != null && context != null) { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentConnected( + requireNotNull(context), + currentCall.toBundle() + ) + ) + } + result.success(true) + } + + "endAllCalls" -> { + val calls = getDataActiveCalls(context) + calls.forEach { + if (it.isAccepted) { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentEnded( + requireNotNull(context), + it.toBundle() + ) + ) + } else { + context?.sendBroadcast( + IncomingCallBroadcastReceiver.getIntentDecline( + requireNotNull(context), + it.toBundle() + ) + ) + } + } + removeAllCalls(context) + result.success(true) + } + + "activeCalls" -> { + result.success(getDataActiveCallsForFlutter(context)) + } + + "getDevicePushTokenVoIP" -> { + result.success("") + } + + "silenceEvents" -> { + val silence = call.arguments as? Boolean ?: false + IncomingCallBroadcastReceiver.silenceEvents = silence + result.success(true) + } + + "requestNotificationPermission" -> { + val map = buildMap { + val args = call.arguments + if (args is Map<*, *>) { + putAll(args as Map) + } + } + incomingCallNotificationManager?.requestNotificationPermission(activity, map) + result.success(true) + } + + "canUseFullScreenIntent" -> { + result.success(incomingCallNotificationManager?.canUseFullScreenIntent() ?: true) + } + + // EDIT - clear the incoming notification/ring (after accept/decline/timeout) + "hideIncomingCall" -> { + val data = Data(call.arguments() ?: HashMap()) + incomingCallSoundPlayerManager?.stop() + incomingCallNotificationManager?.clearIncomingNotification(data.toBundle(), false) + result.success(true) + } + + "ensureFullScreenIntentPermission" -> { + val notificationManager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= 34) { + if (!notificationManager.canUseFullScreenIntent()) { + incomingCallNotificationManager?.requestFullIntentPermission(activity) + } + } + result.success(true) + } + + else -> result.notImplemented() + } + } catch (error: Exception) { + result.error("error", error.message, "") + } } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) + methodChannels.remove(binding.binaryMessenger)?.setMethodCallHandler(null) + eventChannels.remove(binding.binaryMessenger)?.setStreamHandler(null) + instance.incomingCallSoundPlayerManager?.destroy() + instance.incomingCallNotificationManager?.destroy() + instance.incomingCallSoundPlayerManager = null + instance.incomingCallNotificationManager = null } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity + instance.context = binding.activity.applicationContext + instance.activity = binding.activity + binding.addRequestPermissionsResultListener(this) } - override fun onDetachedFromActivityForConfigChanges() { - activity = null - } +override fun onDetachedFromActivityForConfigChanges() { + } - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - } + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + instance.context = binding.activity.applicationContext + instance.activity = binding.activity + binding.addRequestPermissionsResultListener(this) + } - override fun onDetachedFromActivity() { - activity = null - } + override fun onDetachedFromActivity() { + instance.context = null + instance.activity = null + } + + class EventCallbackHandler : EventChannel.StreamHandler { + + private var eventSink: EventChannel.EventSink? = null + + override fun onListen(arguments: Any?, sink: EventChannel.EventSink) { + eventSink = sink + } + + fun send(event: String, body: Map) { + val data = mapOf( + "event" to event, + "body" to body + ) + Handler(Looper.getMainLooper()).post { + eventSink?.success(data) + } + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ): Boolean { + instance.incomingCallNotificationManager?.onRequestPermissionsResult( + instance.activity, + requestCode, + grantResults + ) + return true + } } diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/TransparentActivity.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/TransparentActivity.kt new file mode 100644 index 000000000..52e14f22c --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/TransparentActivity.kt @@ -0,0 +1,45 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle + +class TransparentActivity : Activity() { + + companion object { + fun getIntent(context: Context, action: String, data: Bundle?): Intent { + val intent = Intent(context, TransparentActivity::class.java) + intent.action = action + intent.putExtra("data", data) + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + return intent + } + } + + + override fun onStart() { + super.onStart() + setVisible(false) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val data = intent.getBundleExtra("data") + + val broadcastIntent = IncomingCallBroadcastReceiver.getIntent(this, intent.action!!, data) + broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + sendBroadcast(broadcastIntent) + + val activityIntent = AppUtils.getAppIntent(this, intent.action, data) + startActivity(activityIntent) + + finish() + overridePendingTransition(0, 0) + } +} diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/Utils.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/Utils.kt new file mode 100644 index 000000000..0e51eb8eb --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/Utils.kt @@ -0,0 +1,82 @@ +package io.getstream.video.flutter.stream_video_push_notification + +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import com.fasterxml.jackson.databind.ObjectMapper +import java.lang.ref.WeakReference + + +class Utils { + + companion object { + + private var mapper: ObjectMapper? = null + + + fun getGsonInstance(): ObjectMapper { + if (mapper == null) { + mapper = ObjectMapper() + } + return mapper!! + } + + @JvmStatic + fun dpToPx(dp: Float): Float { + return dp * Resources.getSystem().displayMetrics.density + } + + @JvmStatic + fun pxToDp(px: Float): Float { + return px / Resources.getSystem().displayMetrics.density + } + + @JvmStatic + fun getScreenWidth(): Int { + return Resources.getSystem().displayMetrics.widthPixels + } + + @JvmStatic + fun getScreenHeight(): Int { + return Resources.getSystem().displayMetrics.heightPixels + } + + fun getNavigationBarHeight(context: Context): Int { + val resources = context.resources + val id = resources.getIdentifier( + "navigation_bar_height", "dimen", "android" + ) + return if (id > 0) { + resources.getDimensionPixelSize(id) + } else 0 + } + + fun getStatusBarHeight(context: Context): Int { + val resources = context.resources + val id: Int = + resources.getIdentifier("status_bar_height", "dimen", "android") + return if (id > 0) { + resources.getDimensionPixelSize(id) + } else 0 + } + + fun backToForeground(context: Context) { + val packageName = context.packageName + val intent = context.packageManager.getLaunchIntentForPackage(packageName)?.cloneFilter() + intent?.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + fun >> C.reapCollection(): C { + this.removeAll { + it.get() == null + } + return this + } + + fun isTablet(context: Context): Boolean { + return context.resources.getBoolean(R.bool.isTablet) + } + } +} diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/widgets/RippleRelativeLayout.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/widgets/RippleRelativeLayout.kt new file mode 100644 index 000000000..5f8f32b23 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/widgets/RippleRelativeLayout.kt @@ -0,0 +1,154 @@ +package io.getstream.video.flutter.stream_video_push_notification.widgets + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.RelativeLayout +import io.getstream.video.flutter.stream_video_push_notification.R +import io.getstream.video.flutter.stream_video_push_notification.Utils +import kotlin.math.min + + +class RippleRelativeLayout : RelativeLayout { + private var rippleColor = 0 + private var rippleRadius = 0f + private var rippleDurationTime = 0 + private var rippleAmount = 0 + private var rippleDelay = 0 + private var rippleScale = 0f + private var paint: Paint = Paint() + var isRippleAnimationRunning = false + private set + private var animatorSet: AnimatorSet? = null + private var animatorList: ArrayList? = null + private var rippleParams: LayoutParams? = null + private val rippleViewList = ArrayList() + + constructor(context: Context?) : super(context) {} + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + init(context, attrs) + } + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) { + init(context, attrs) + } + + @SuppressLint("CustomViewStyleable") + private fun init(context: Context, attrs: AttributeSet?) { + if (isInEditMode) return + + val typedArray: TypedArray = + context.obtainStyledAttributes(attrs, R.styleable.ripple_relativeLayout) + rippleColor = typedArray.getColor( + R.styleable.ripple_relativeLayout_ripple_color, + resources.getColor(R.color.ripple_main_color) + ) + rippleRadius = typedArray.getDimension( + R.styleable.ripple_relativeLayout_ripple_radius, + Utils.dpToPx(30f) + ) + rippleDurationTime = typedArray.getInt( + R.styleable.ripple_relativeLayout_ripple_duration, + DEFAULT_DURATION_TIME + ) + rippleAmount = + typedArray.getInt(R.styleable.ripple_relativeLayout_ripple_amount, DEFAULT_RIPPLE_COUNT) + rippleScale = + typedArray.getFloat(R.styleable.ripple_relativeLayout_ripple_scale, DEFAULT_SCALE) + typedArray.recycle() + + rippleDelay = rippleDurationTime / rippleAmount.coerceAtLeast(1) + paint = Paint() + paint.isAntiAlias = true + paint.style = Paint.Style.FILL + paint.color = rippleColor + rippleParams = LayoutParams( + (2 * (rippleRadius)).toInt(), + (2 * (rippleRadius)).toInt() + ) + rippleParams!!.addRule(CENTER_IN_PARENT, TRUE) + animatorSet = AnimatorSet() + animatorSet!!.interpolator = AccelerateDecelerateInterpolator() + animatorList = ArrayList() + for (i in 0 until rippleAmount) { + val rippleView: RippleView = RippleView(getContext()) + addView(rippleView, rippleParams) + rippleViewList.add(rippleView) + val scaleXAnimator = ObjectAnimator.ofFloat(rippleView, "ScaleX", 1.0f, rippleScale) + scaleXAnimator.repeatCount = ObjectAnimator.INFINITE + scaleXAnimator.repeatMode = ObjectAnimator.RESTART + scaleXAnimator.startDelay = (i * rippleDelay).toLong() + scaleXAnimator.duration = rippleDurationTime.toLong() + animatorList!!.add(scaleXAnimator) + val scaleYAnimator = ObjectAnimator.ofFloat(rippleView, "ScaleY", 1.0f, rippleScale) + scaleYAnimator.repeatCount = ObjectAnimator.INFINITE + scaleYAnimator.repeatMode = ObjectAnimator.RESTART + scaleYAnimator.startDelay = (i * rippleDelay).toLong() + scaleYAnimator.duration = rippleDurationTime.toLong() + animatorList!!.add(scaleYAnimator) + val alphaAnimator = ObjectAnimator.ofFloat(rippleView, "Alpha", 1.0f, 0f) + alphaAnimator.repeatCount = ObjectAnimator.INFINITE + alphaAnimator.repeatMode = ObjectAnimator.RESTART + alphaAnimator.startDelay = (i * rippleDelay).toLong() + alphaAnimator.duration = rippleDurationTime.toLong() + animatorList!!.add(alphaAnimator) + } + animatorSet!!.playTogether(animatorList) + startRippleAnimation() + } + + private inner class RippleView(context: Context?) : View(context) { + override fun onDraw(canvas: Canvas) { + val radius = min(width, height) / 2f + canvas.drawCircle(radius, radius, radius, paint) + } + + init { + visibility = INVISIBLE + } + } + + fun startRippleAnimation() { + if (!isRippleAnimationRunning) { + for (rippleView in rippleViewList) { + rippleView.visibility = VISIBLE + } + animatorSet!!.start() + isRippleAnimationRunning = true + } + } + + fun stopRippleAnimation() { + if (isRippleAnimationRunning) { + animatorSet!!.end() + isRippleAnimationRunning = false + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + if (isRippleAnimationRunning) { + stopRippleAnimation() + } else if (this::animatorSet != null) { + animatorSet!!.cancel() + } + } + + companion object { + private const val DEFAULT_RIPPLE_COUNT = 5 + private const val DEFAULT_DURATION_TIME = 6000 + private const val DEFAULT_SCALE = 6.0f + } +} \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/anim/shake_anim.xml b/packages/stream_video_push_notification/android/src/main/res/anim/shake_anim.xml new file mode 100644 index 000000000..5e3736c12 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/anim/shake_anim.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_accept.png b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_accept.png new file mode 100644 index 000000000..0cc792842 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_accept.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_call_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_call_missed.png new file mode 100644 index 000000000..e654a4a83 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_call_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_decline.png b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_decline.png new file mode 100644 index 000000000..c9feb1649 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_decline.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_video.png b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_video.png new file mode 100644 index 000000000..a1c30ba14 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_video.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_video_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_video_missed.png new file mode 100644 index 000000000..98cc9c713 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-hdpi/ic_video_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_accept.png b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_accept.png new file mode 100644 index 000000000..70d28ae05 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_accept.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_call_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_call_missed.png new file mode 100644 index 000000000..0e07b3edd Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_call_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_decline.png b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_decline.png new file mode 100644 index 000000000..0d0c5eb0e Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_decline.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_video.png b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_video.png new file mode 100644 index 000000000..6b8c8f964 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_video.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_video_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_video_missed.png new file mode 100644 index 000000000..d83aeaada Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-mdpi/ic_video_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-v21/bg_button_accept.xml b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/bg_button_accept.xml new file mode 100644 index 000000000..ad01721b9 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/bg_button_accept.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-v21/bg_button_decline.xml b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/bg_button_decline.xml new file mode 100644 index 000000000..1002e348a --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/bg_button_decline.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-v21/rounded_button_accept.xml b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/rounded_button_accept.xml new file mode 100644 index 000000000..d0d2b19e1 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/rounded_button_accept.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-v21/rounded_button_decline.xml b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/rounded_button_decline.xml new file mode 100644 index 000000000..1e636310e --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable-v21/rounded_button_decline.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_accept.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_accept.png new file mode 100644 index 000000000..6e4bd586e Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_accept.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_call_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_call_missed.png new file mode 100644 index 000000000..eb56a3c12 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_call_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_decline.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_decline.png new file mode 100644 index 000000000..0e3476c78 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_decline.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_video.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_video.png new file mode 100644 index 000000000..a7f97826c Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_video.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_video_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_video_missed.png new file mode 100644 index 000000000..8bf16d8a4 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xhdpi/ic_video_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_accept.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_accept.png new file mode 100644 index 000000000..1b531be1b Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_accept.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_call_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_call_missed.png new file mode 100644 index 000000000..6c81a0473 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_call_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_decline.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_decline.png new file mode 100644 index 000000000..f81679f56 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_decline.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_video.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_video.png new file mode 100644 index 000000000..b9fbf42e8 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_video.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_video_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_video_missed.png new file mode 100644 index 000000000..19fbc002a Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxhdpi/ic_video_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_accept.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_accept.png new file mode 100644 index 000000000..d452dff1d Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_accept.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_call_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_call_missed.png new file mode 100644 index 000000000..b52e7bad4 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_call_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_decline.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_decline.png new file mode 100644 index 000000000..6457cb11a Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_decline.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_default_avatar.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_default_avatar.png new file mode 100644 index 000000000..e69f2271a Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_default_avatar.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_video.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_video.png new file mode 100644 index 000000000..3c46b8df8 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_video.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_video_missed.png b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_video_missed.png new file mode 100644 index 000000000..c9adaa5a0 Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable-xxxhdpi/ic_video_missed.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable/bg_button_accept.xml b/packages/stream_video_push_notification/android/src/main/res/drawable/bg_button_accept.xml new file mode 100644 index 000000000..31380adc4 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable/bg_button_accept.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable/bg_button_decline.xml b/packages/stream_video_push_notification/android/src/main/res/drawable/bg_button_decline.xml new file mode 100644 index 000000000..eba6f1214 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable/bg_button_decline.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable/rounded_button_accept.xml b/packages/stream_video_push_notification/android/src/main/res/drawable/rounded_button_accept.xml new file mode 100644 index 000000000..0bdd7e68f --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable/rounded_button_accept.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable/rounded_button_decline.xml b/packages/stream_video_push_notification/android/src/main/res/drawable/rounded_button_decline.xml new file mode 100644 index 000000000..6a96e4874 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/drawable/rounded_button_decline.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/drawable/transparent.png b/packages/stream_video_push_notification/android/src/main/res/drawable/transparent.png new file mode 100644 index 000000000..7e4187b0f Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/drawable/transparent.png differ diff --git a/packages/stream_video_push_notification/android/src/main/res/layout-w600dp-land/activity_incoming_call.xml b/packages/stream_video_push_notification/android/src/main/res/layout-w600dp-land/activity_incoming_call.xml new file mode 100644 index 000000000..a956455a0 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/layout-w600dp-land/activity_incoming_call.xml @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/layout/activity_incoming_call.xml b/packages/stream_video_push_notification/android/src/main/res/layout/activity_incoming_call.xml new file mode 100644 index 000000000..44e34c823 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/layout/activity_incoming_call.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_miss_notification.xml b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_miss_notification.xml new file mode 100644 index 000000000..0a6880616 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_miss_notification.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_miss_small_notification.xml b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_miss_small_notification.xml new file mode 100644 index 000000000..098209b27 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_miss_small_notification.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_notification.xml b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_notification.xml new file mode 100644 index 000000000..1e39f010c --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_notification.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_small_ex_notification.xml b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_small_ex_notification.xml new file mode 100644 index 000000000..851848073 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_small_ex_notification.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_small_notification.xml b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_small_notification.xml new file mode 100644 index 000000000..f79f5e9da --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/layout/layout_custom_small_notification.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_video_push_notification/android/src/main/res/raw/ringtone_default.mp3 b/packages/stream_video_push_notification/android/src/main/res/raw/ringtone_default.mp3 new file mode 100644 index 000000000..67a904d8d Binary files /dev/null and b/packages/stream_video_push_notification/android/src/main/res/raw/ringtone_default.mp3 differ diff --git a/packages/stream_video_push_notification/android/src/main/res/values-sw600dp/dimens.xml b/packages/stream_video_push_notification/android/src/main/res/values-sw600dp/dimens.xml new file mode 100644 index 000000000..d3a0e92c9 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/values-sw600dp/dimens.xml @@ -0,0 +1,4 @@ + + + true + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/values/attrs.xml b/packages/stream_video_push_notification/android/src/main/res/values/attrs.xml new file mode 100644 index 000000000..4a2d5fff6 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/values/attrs.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/values/colors.xml b/packages/stream_video_push_notification/android/src/main/res/values/colors.xml new file mode 100644 index 000000000..e7cad1edd --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + + #4CAF50 + #A6FFA9 + #F44336 + #FB8D85 + + #FFFFFF + #80ffffff + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/values/dimens.xml b/packages/stream_video_push_notification/android/src/main/res/values/dimens.xml new file mode 100644 index 000000000..7c5cdee9b --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/values/dimens.xml @@ -0,0 +1,25 @@ + + + false + 5dp + 10dp + 15dp + 20dp + 25dp + 30dp + 35dp + 40dp + 48dp + 50dp + 60dp + + -50dp + 120dp + 60dp + 120dp + 150dp + + 24sp + 14sp + 12sp + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/values/strings.xml b/packages/stream_video_push_notification/android/src/main/res/values/strings.xml new file mode 100644 index 000000000..d74ffdd11 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + + Accept + Decline + Missed call + Call back + Calling… + Hang up + Notification Permission + Notification permission is required, Please allow notification permission from setting. + Maintaining an active call notification for ongoing calls until user ends the call + \ No newline at end of file diff --git a/packages/stream_video_push_notification/android/src/main/res/values/styles.xml b/packages/stream_video_push_notification/android/src/main/res/values/styles.xml new file mode 100644 index 000000000..de7a0e8b3 --- /dev/null +++ b/packages/stream_video_push_notification/android/src/main/res/values/styles.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/stream_video_push_notification/ios/Classes/Call.swift b/packages/stream_video_push_notification/ios/Classes/Call.swift new file mode 100644 index 000000000..e6ce1bd66 --- /dev/null +++ b/packages/stream_video_push_notification/ios/Classes/Call.swift @@ -0,0 +1,303 @@ +import AVFoundation +import Foundation + +public class Call: NSObject { + public var uuid: UUID + public var data: CallData + public var isOutGoing: Bool + public var handle: String? + + var stateDidChange: (() -> Void)? + var hasStartedConnectDidChange: (() -> Void)? + var hasConnectDidChange: (() -> Void)? + var hasEndedDidChange: (() -> Void)? + + var connectData: Date? { + didSet { + stateDidChange?() + hasStartedConnectDidChange?() + } + } + + var connectedData: Date? { + didSet { + stateDidChange?() + hasConnectDidChange?() + } + } + + var endDate: Date? { + didSet { + stateDidChange?() + hasEndedDidChange?() + } + } + + var isOnHold = false { + didSet { + stateDidChange?() + } + } + + var isMuted = false { + didSet { + + } + } + + var hasStartedConnecting: Bool { + get { + return connectData != nil + } + set { + connectData = newValue ? Date() : nil + } + } + + var hasConnected: Bool { + get { + return connectedData != nil + } + set { + connectedData = newValue ? Date() : nil + } + } + + var hasEnded: Bool { + get { + return endDate != nil + } + set { + endDate = newValue ? Date() : nil + } + } + + var duration: TimeInterval { + guard let connectDate = connectedData else { + return 0 + } + return Date().timeIntervalSince(connectDate) + } + + init(uuid: UUID, data: CallData, isOutGoing: Bool = false) { + self.uuid = uuid + self.data = data + self.isOutGoing = isOutGoing + } + + var startCallCompletion: ((Bool) -> Void)? + + func startCall( + withAudioSession audioSession: AVAudioSession, completion: ((_ success: Bool) -> Void)? + ) { + startCallCompletion = completion + hasStartedConnecting = true + } + + var answerCallCompletion: ((Bool) -> Void)? + + func answerCall( + withAudioSession audioSession: AVAudioSession, completion: ((_ success: Bool) -> Void)? + ) { + answerCallCompletion = completion + hasStartedConnecting = true + } + + var connectedCallCompletion: ((Bool) -> Void)? + + func connectedCall(completion: ((_ success: Bool) -> Void)?) { + connectedCallCompletion = completion + hasConnected = true + } + + func endCall() { + hasEnded = true + } + + func startAudio() { + + } +} + +@objc public class CallData: NSObject { + @objc public var uuid: String + @objc public var callerName: String + @objc public var handle: String + @objc public var type: Int + @objc public var duration: Int + @objc public var isAccepted: Bool + @objc public var extra: NSDictionary + + //iOS + @objc public var iconName: String + @objc public var handleType: String + @objc public var useComplexHandle: Bool + @objc public var supportsVideo: Bool + @objc public var maximumCallGroups: Int + @objc public var maximumCallsPerCallGroup: Int + @objc public var supportsDTMF: Bool + @objc public var supportsHolding: Bool + @objc public var supportsGrouping: Bool + @objc public var supportsUngrouping: Bool + @objc public var includesCallsInRecents: Bool + @objc public var ringtonePath: String + @objc public var configureAudioSession: Bool + @objc public var audioSessionMode: String + @objc public var audioSessionActive: Bool + @objc public var audioSessionPreferredSampleRate: Double + @objc public var audioSessionPreferredIOBufferDuration: Double + + @objc public init(id: String, callerName: String, handle: String, type: Int) { + self.uuid = id + self.callerName = callerName + self.handle = handle + self.type = type + self.useComplexHandle = false + self.duration = 30000 + self.isAccepted = false + self.extra = [:] + self.iconName = "AppLogo" + self.handleType = "" + self.supportsVideo = true + self.maximumCallGroups = 2 + self.maximumCallsPerCallGroup = 1 + self.supportsDTMF = true + self.supportsHolding = true + self.supportsGrouping = true + self.supportsUngrouping = true + self.includesCallsInRecents = true + self.ringtonePath = "" + self.configureAudioSession = true + self.audioSessionMode = "" + self.audioSessionActive = true + self.audioSessionPreferredSampleRate = 44100.0 + self.audioSessionPreferredIOBufferDuration = 0.005 + } + + @objc public convenience init(args: NSDictionary) { + var argsConvert = [String: Any?]() + for (k, v) in args { + guard let key = k as? String else { continue } + argsConvert[key] = v + } + self.init(args: argsConvert) + } + + public init(args: [String: Any?]) { + self.uuid = args["id"] as? String ?? "" + self.callerName = args["callerName"] as? String ?? "" + self.handle = args["handle"] as? String ?? "" + self.type = args["type"] as? Int ?? 0 + self.duration = args["duration"] as? Int ?? 30000 + self.isAccepted = args["isAccepted"] as? Bool ?? false + self.extra = args["extra"] as? NSDictionary ?? [:] + + if let ios = args["ios"] as? [String: Any] { + self.iconName = ios["iconName"] as? String ?? "AppLogo" + self.handleType = ios["handleType"] as? String ?? "" + self.useComplexHandle = ios["useComplexHandle"] as? Bool ?? false + self.supportsVideo = ios["supportsVideo"] as? Bool ?? true + self.maximumCallGroups = ios["maximumCallGroups"] as? Int ?? 2 + self.maximumCallsPerCallGroup = ios["maximumCallsPerCallGroup"] as? Int ?? 1 + self.supportsDTMF = ios["supportsDTMF"] as? Bool ?? true + self.supportsHolding = ios["supportsHolding"] as? Bool ?? true + self.supportsGrouping = ios["supportsGrouping"] as? Bool ?? true + self.supportsUngrouping = ios["supportsUngrouping"] as? Bool ?? true + self.includesCallsInRecents = ios["includesCallsInRecents"] as? Bool ?? true + self.ringtonePath = ios["ringtonePath"] as? String ?? "" + self.configureAudioSession = ios["configureAudioSession"] as? Bool ?? true + self.audioSessionMode = ios["audioSessionMode"] as? String ?? "" + self.audioSessionActive = ios["audioSessionActive"] as? Bool ?? true + self.audioSessionPreferredSampleRate = + ios["audioSessionPreferredSampleRate"] as? Double ?? 44100.0 + self.audioSessionPreferredIOBufferDuration = + ios["audioSessionPreferredIOBufferDuration"] as? Double ?? 0.005 + } else { + self.iconName = args["iconName"] as? String ?? "AppLogo" + self.handleType = args["handleType"] as? String ?? "" + self.useComplexHandle = args["useComplexHandle"] as? Bool ?? false + self.supportsVideo = args["supportsVideo"] as? Bool ?? true + self.maximumCallGroups = args["maximumCallGroups"] as? Int ?? 2 + self.maximumCallsPerCallGroup = args["maximumCallsPerCallGroup"] as? Int ?? 1 + self.supportsDTMF = args["supportsDTMF"] as? Bool ?? true + self.supportsHolding = args["supportsHolding"] as? Bool ?? true + self.supportsGrouping = args["supportsGrouping"] as? Bool ?? true + self.supportsUngrouping = args["supportsUngrouping"] as? Bool ?? true + self.includesCallsInRecents = args["includesCallsInRecents"] as? Bool ?? true + self.ringtonePath = args["ringtonePath"] as? String ?? "" + self.configureAudioSession = args["configureAudioSession"] as? Bool ?? true + self.audioSessionMode = args["audioSessionMode"] as? String ?? "" + self.audioSessionActive = args["audioSessionActive"] as? Bool ?? true + self.audioSessionPreferredSampleRate = + args["audioSessionPreferredSampleRate"] as? Double ?? 44100.0 + self.audioSessionPreferredIOBufferDuration = + args["audioSessionPreferredIOBufferDuration"] as? Double ?? 0.005 + } + } + + open func toJSON() -> [String: Any] { + let ios: [String: Any] = [ + "iconName": iconName, + "handleType": handleType, + "useComplexHandle": useComplexHandle, + "supportsVideo": supportsVideo, + "maximumCallGroups": maximumCallGroups, + "maximumCallsPerCallGroup": maximumCallsPerCallGroup, + "supportsDTMF": supportsDTMF, + "supportsHolding": supportsHolding, + "supportsGrouping": supportsGrouping, + "supportsUngrouping": supportsUngrouping, + "includesCallsInRecents": includesCallsInRecents, + "ringtonePath": ringtonePath, + "configureAudioSession": configureAudioSession, + "audioSessionMode": audioSessionMode, + "audioSessionActive": audioSessionActive, + "audioSessionPreferredSampleRate": audioSessionPreferredSampleRate, + "audioSessionPreferredIOBufferDuration": audioSessionPreferredIOBufferDuration, + ] + let map: [String: Any] = [ + "uuid": uuid, + "id": uuid, + "callerName": callerName, + "handle": handle, + "type": type, + "duration": duration, + "isAccepted": isAccepted, + "extra": extra, + "ios": ios, + ] + return map + } + + func getEncryptHandle() -> String { + if !useComplexHandle { + return handle + } + do { + var map: [String: Any] = [:] + + map["callerName"] = callerName + map["handle"] = handle + + if let mapExtras = extra as? [String: Any] { + for (key, value) in mapExtras { + map[key] = value + } + } else { + return String( + format: "{\"callerName\":\"%@\", \"handle\":\"%@\"}", callerName, handle + ).encryptHandle() + } + + let mapData = try JSONSerialization.data(withJSONObject: map, options: .prettyPrinted) + let mapString: String = String(data: mapData, encoding: .utf8) ?? "" + + return mapString.encryptHandle() + } catch { + return String(format: "{\"callerName\":\"%@\", \"handle\":\"%@\"}", callerName, handle) + .encryptHandle() + } + + } + +} diff --git a/packages/stream_video_push_notification/ios/Classes/NSUserActivity.swift b/packages/stream_video_push_notification/ios/Classes/NSUserActivity.swift new file mode 100644 index 000000000..9102b9985 --- /dev/null +++ b/packages/stream_video_push_notification/ios/Classes/NSUserActivity.swift @@ -0,0 +1,48 @@ +import Foundation +import Intents + +extension NSUserActivity: StartCallConvertible { + + public var handle: String? { + guard + let interaction = interaction, + let startCallIntent = interaction.intent as? SupportedStartCallIntent, + let contact = startCallIntent.contacts?.first + else { + return nil + } + + return contact.personHandle?.value + } + + public var isVideo: Bool? { + guard + let interaction = interaction, + let startCallIntent = interaction.intent as? SupportedStartCallIntent + else { + return nil + } + + return startCallIntent is INStartVideoCallIntent + } + +} + +protocol StartCallConvertible { + var handle: String? { get } + var isVideo: Bool? { get } +} + +extension StartCallConvertible { + var isVideo: Bool? { + return nil + } + +} + +protocol SupportedStartCallIntent { + var contacts: [INPerson]? { get } +} + +extension INStartAudioCallIntent: SupportedStartCallIntent {} +extension INStartVideoCallIntent: SupportedStartCallIntent {} diff --git a/packages/stream_video_push_notification/ios/Classes/StreamCallKitCallController.swift b/packages/stream_video_push_notification/ios/Classes/StreamCallKitCallController.swift new file mode 100644 index 000000000..379e0af31 --- /dev/null +++ b/packages/stream_video_push_notification/ios/Classes/StreamCallKitCallController.swift @@ -0,0 +1,194 @@ +import CallKit +import Foundation + +@available(iOS 10.0, *) +class StreamCallKitCallController: NSObject { + + private let callController = CXCallController() + private var sharedProvider: CXProvider? = nil + private(set) var calls = [Call]() + + func setSharedProvider(_ sharedProvider: CXProvider) { + self.sharedProvider = sharedProvider + } + + func startCall(_ data: CallData) { + let handle = CXHandle( + type: self.getHandleType(data.handleType), value: data.getEncryptHandle()) + + guard let uuid = UUID(uuidString: data.uuid) else { + print("Error: Invalid UUID string: \(data.uuid)") + return + } + + let startCallAction = CXStartCallAction(call: uuid, handle: handle) + startCallAction.isVideo = data.type > 0 + + let callTransaction = CXTransaction() + callTransaction.addAction(startCallAction) + + self.requestCall( + callTransaction, action: "startCall", + completion: { _ in + let callUpdate = CXCallUpdate() + callUpdate.remoteHandle = handle + callUpdate.supportsDTMF = data.supportsDTMF + callUpdate.supportsHolding = data.supportsHolding + callUpdate.supportsGrouping = data.supportsGrouping + callUpdate.supportsUngrouping = data.supportsUngrouping + callUpdate.hasVideo = data.type > 0 ? true : false + callUpdate.localizedCallerName = data.callerName + self.sharedProvider?.reportCall(with: uuid, updated: callUpdate) + }) + } + + func muteCall(call: Call, isMuted: Bool) { + let muteAction = CXSetMutedCallAction(call: call.uuid, muted: isMuted) + + let callTransaction = CXTransaction() + callTransaction.addAction(muteAction) + + self.requestCall(callTransaction, action: "muteCall") + } + + func holdCall(call: Call, onHold: Bool) { + let onHoldAction = CXSetHeldCallAction(call: call.uuid, onHold: onHold) + + let callTransaction = CXTransaction() + callTransaction.addAction(onHoldAction) + + self.requestCall(callTransaction, action: "holdCall") + } + + func endCall(call: Call) { + let endCallAction = CXEndCallAction(call: call.uuid) + + let callTransaction = CXTransaction() + callTransaction.addAction(endCallAction) + + self.requestCall(callTransaction, action: "endCall") + } + + func connectedCall(call: Call) { + let callItem = self.callWithUUID(uuid: call.uuid) + callItem?.connectedCall(completion: nil) + + let answerAction = CXAnswerCallAction(call: call.uuid) + let transaction = CXTransaction(action: answerAction) + + callController.request(transaction) { error in + if let error = error { + print("Error answering call: \(error.localizedDescription)") + } else { + // Call successfully answered + } + } + } + + func endAllCalls() { + let calls = callController.callObserver.calls + for call in calls { + let endCallAction = CXEndCallAction(call: call.uuid) + + let callTransaction = CXTransaction() + callTransaction.addAction(endCallAction) + + self.requestCall(callTransaction, action: "endAllCalls") + } + } + + func activeCalls() -> [[String: Any]] { + let calls = callController.callObserver.calls + var json = [[String: Any]]() + for call in calls { + let callItem = self.callWithUUID(uuid: call.uuid) + if callItem != nil { + var item: [String: Any] = callItem!.data.toJSON() + item["accepted"] = callItem?.hasConnected + json.append(item) + } else { + let item: [String: String] = ["id": call.uuid.uuidString] + json.append(item) + } + } + return json + } + + func setHold(call: Call, onHold: Bool) { + let handleCall = CXSetHeldCallAction(call: call.uuid, onHold: onHold) + + let callTransaction = CXTransaction() + callTransaction.addAction(handleCall) + self.requestCall(callTransaction, action: "holdCall") + } + + private func requestCall( + _ transaction: CXTransaction, action: String, completion: ((Bool) -> Void)? = nil + ) { + callController.request(transaction) { error in + if let error = error { + //fail + print("Error requesting transaction: \(error)") + } else { + if action == "startCall" { + //TODO: push notification for Start Call + } else if action == "endCall" || action == "endAllCalls" { + //TODO: push notification for End Call + } + completion?(error == nil) + print("Requested transaction successfully: \(action)") + } + } + } + + private func getHandleType(_ handleType: String?) -> CXHandle.HandleType { + var typeDefault = CXHandle.HandleType.generic + switch handleType { + case "number": + typeDefault = CXHandle.HandleType.phoneNumber + break + case "email": + typeDefault = CXHandle.HandleType.emailAddress + default: + typeDefault = CXHandle.HandleType.generic + } + return typeDefault + } + + static let callsChangedNotification = Notification.Name("CallsChangedNotification") + var callsChangedHandler: (() -> Void)? + + func callWithUUID(uuid: UUID) -> Call? { + guard let idx = calls.firstIndex(where: { $0.uuid == uuid }) else { return nil } + return calls[idx] + } + + func addCall(_ call: Call) { + calls.append(call) + call.stateDidChange = { [weak self] in + guard let strongSelf = self else { return } + strongSelf.callsChangedHandler?() + strongSelf.postCallNotification() + } + callsChangedHandler?() + postCallNotification() + } + + func removeCall(_ call: Call) { + guard let idx = calls.firstIndex(where: { $0 === call }) else { return } + calls.remove(at: idx) + callsChangedHandler?() + postCallNotification() + } + + func removeAllCalls() { + calls.removeAll() + callsChangedHandler?() + postCallNotification() + } + + private func postCallNotification() { + NotificationCenter.default.post(name: type(of: self).callsChangedNotification, object: self) + } + +} diff --git a/packages/stream_video_push_notification/ios/Classes/StreamVideoCallkitManager.swift b/packages/stream_video_push_notification/ios/Classes/StreamVideoCallkitManager.swift new file mode 100644 index 000000000..e3641fe65 --- /dev/null +++ b/packages/stream_video_push_notification/ios/Classes/StreamVideoCallkitManager.swift @@ -0,0 +1,661 @@ +import AVFoundation +import CallKit +import Flutter +import UIKit + +@available(iOS 10.0, *) +public class StreamVideoCallkitManager: NSObject, CXProviderDelegate { + + @objc public private(set) static var sharedInstance: StreamVideoCallkitManager! + + private var eventHandler: EventCallbackHandler + + private var callController: StreamCallKitCallController + + private var sharedProvider: CXProvider? = nil + + private var outgoingCall: Call? + private var answerCall: Call? + + private var data: CallData? + private var isFromPushKit: Bool = false + private var silenceEvents: Bool = false + + public init( + eventHandler: EventCallbackHandler + ) { + self.eventHandler = eventHandler + callController = StreamCallKitCallController() + } + + private func sendEvent(_ event: String, _ body: [String: Any?]?) { + if silenceEvents { + print(event, " silenced") + return + } else { + eventHandler.send(event, body ?? [:]) + } + + } + + @objc public func sendEventCustom(_ event: String, body: NSDictionary?) { + eventHandler.send(event, body ?? [:]) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "showIncomingCall": + guard let args = call.arguments else { + result(true) + return + } + if let getArgs = args as? [String: Any] { + self.data = CallData(args: getArgs) + showIncomingCall(self.data!, fromPushKit: false) + } + result(true) + break + case "showMissCallNotification": + result(true) + break + case "startCall": + guard let args = call.arguments else { + result(true) + return + } + if let getArgs = args as? [String: Any] { + self.data = CallData(args: getArgs) + self.startCall(self.data!, fromPushKit: false) + } + result(true) + break + case "endCall": + guard let args = call.arguments else { + result(true) + return + } + if self.isFromPushKit { + self.endCall(self.data!) + } else { + if let getArgs = args as? [String: Any] { + self.data = CallData(args: getArgs) + self.endCall(self.data!) + } + } + result(true) + break + case "muteCall": + guard let args = call.arguments as? [String: Any], + let callId = args["id"] as? String, + let isMuted = args["isMuted"] as? Bool + else { + result(true) + return + } + + self.muteCall(callId, isMuted: isMuted) + result(true) + break + case "isMuted": + guard let args = call.arguments as? [String: Any], + let callId = args["id"] as? String + else { + result(false) + return + } + guard let callUUID = UUID(uuidString: callId), + let call = self.callController.callWithUUID(uuid: callUUID) + else { + result(false) + return + } + result(call.isMuted) + break + case "holdCall": + guard let args = call.arguments as? [String: Any], + let callId = args["id"] as? String, + let onHold = args["isOnHold"] as? Bool + else { + result(true) + return + } + self.holdCall(callId, onHold: onHold) + result(true) + break + case "callConnected": + guard let args = call.arguments else { + result(true) + return + } + if self.isFromPushKit { + self.connectedCall(self.data!) + } else { + if let getArgs = args as? [String: Any] { + self.data = CallData(args: getArgs) + self.connectedCall(self.data!) + } + } + result(true) + break + case "activeCalls": + result(self.callController.activeCalls()) + break + case "endAllCalls": + self.callController.endAllCalls() + result(true) + break + case "silenceEvents": + guard let silence = call.arguments as? Bool else { + result(true) + return + } + + self.silenceEvents = silence + result(true) + break + case "requestNotificationPermission": + result(true) + break + default: + result(FlutterMethodNotImplemented) + } + } + + @objc public func getAcceptedCall() -> CallData? { + NSLog( + "Call data ids \(String(describing: data?.uuid)) \(String(describing: answerCall?.uuid.uuidString))" + ) + if data?.uuid.lowercased() == answerCall?.uuid.uuidString.lowercased() { + return data + } + return nil + } + + @objc public func showIncomingCall(_ data: CallData, fromPushKit: Bool) { + self.isFromPushKit = fromPushKit + if fromPushKit { + self.data = data + } + + var handle: CXHandle? + handle = CXHandle(type: self.getHandleType(data.handleType), value: data.getEncryptHandle()) + + let callUpdate = CXCallUpdate() + callUpdate.remoteHandle = handle + callUpdate.supportsDTMF = data.supportsDTMF + callUpdate.supportsHolding = data.supportsHolding + callUpdate.supportsGrouping = data.supportsGrouping + callUpdate.supportsUngrouping = data.supportsUngrouping + callUpdate.hasVideo = data.type > 0 ? true : false + callUpdate.localizedCallerName = data.callerName + + initCallkitProvider(data) + + guard let uuid = UUID(uuidString: data.uuid) else { return } + + self.configureAudioSession() + self.sharedProvider?.reportNewIncomingCall(with: uuid, update: callUpdate) { error in + if error == nil { + self.configureAudioSession() + let call = Call(uuid: uuid, data: data) + call.handle = data.handle + self.callController.addCall(call) + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_INCOMING, data.toJSON()) + self.endCallNotExist(data) + } + } + } + + @objc public func showIncomingCall( + _ data: CallData, fromPushKit: Bool, completion: @escaping () -> Void + ) { + self.isFromPushKit = fromPushKit + if fromPushKit { + self.data = data + } + + var handle: CXHandle? + handle = CXHandle(type: self.getHandleType(data.handleType), value: data.getEncryptHandle()) + + let callUpdate = CXCallUpdate() + callUpdate.remoteHandle = handle + callUpdate.supportsDTMF = data.supportsDTMF + callUpdate.supportsHolding = data.supportsHolding + callUpdate.supportsGrouping = data.supportsGrouping + callUpdate.supportsUngrouping = data.supportsUngrouping + callUpdate.hasVideo = data.type > 0 ? true : false + callUpdate.localizedCallerName = data.callerName + + initCallkitProvider(data) + + guard let uuid = UUID(uuidString: data.uuid) else { return } + + self.sharedProvider?.reportNewIncomingCall(with: uuid, update: callUpdate) { error in + if error == nil { + self.configureAudioSession() + let call = Call(uuid: uuid, data: data) + call.handle = data.handle + self.callController.addCall(call) + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_INCOMING, data.toJSON()) + self.endCallNotExist(data) + } + completion() + } + } + + @objc public func startCall(_ data: CallData, fromPushKit: Bool) { + self.isFromPushKit = fromPushKit + if fromPushKit { + self.data = data + } + initCallkitProvider(data) + self.callController.startCall(data) + } + + @objc public func muteCall(_ callId: String, isMuted: Bool) { + guard let callId = UUID(uuidString: callId), + let call = self.callController.callWithUUID(uuid: callId) + else { + return + } + if call.isMuted == isMuted { + self.sendMuteEvent(callId.uuidString, isMuted) + } else { + self.callController.muteCall(call: call, isMuted: isMuted) + } + } + + @objc public func holdCall(_ callId: String, onHold: Bool) { + guard let callId = UUID(uuidString: callId), + let call = self.callController.callWithUUID(uuid: callId) + else { + return + } + if call.isOnHold == onHold { + self.sendHoldEvent(callId.uuidString, onHold) + } else { + self.callController.holdCall(call: call, onHold: onHold) + } + } + + @objc public func endCall(_ data: CallData) { + var call: Call? = nil + if self.isFromPushKit { + call = Call(uuid: UUID(uuidString: self.data!.uuid)!, data: data) + self.isFromPushKit = false + self.sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_ENDED, data.toJSON()) + } else { + call = Call(uuid: UUID(uuidString: data.uuid)!, data: data) + } + self.callController.endCall(call: call!) + } + + @objc public func connectedCall(_ data: CallData) { + var call: Call? = nil + if self.isFromPushKit { + call = Call(uuid: UUID(uuidString: self.data!.uuid)!, data: data) + self.isFromPushKit = false + } else { + call = Call(uuid: UUID(uuidString: data.uuid)!, data: data) + } + self.callController.connectedCall(call: call!) + } + + @objc public func activeCalls() -> [[String: Any]] { + return self.callController.activeCalls() + } + + @objc public func endAllCalls() { + self.isFromPushKit = false + self.callController.endAllCalls() + } + + public func saveEndCall(_ uuid: String, _ reason: Int) { + switch reason { + case 1: + self.sharedProvider?.reportCall( + with: UUID(uuidString: uuid)!, endedAt: Date(), reason: CXCallEndedReason.failed) + break + case 2, 6: + self.sharedProvider?.reportCall( + with: UUID(uuidString: uuid)!, endedAt: Date(), + reason: CXCallEndedReason.remoteEnded) + break + case 3: + self.sharedProvider?.reportCall( + with: UUID(uuidString: uuid)!, endedAt: Date(), reason: CXCallEndedReason.unanswered + ) + break + case 4: + self.sharedProvider?.reportCall( + with: UUID(uuidString: uuid)!, endedAt: Date(), + reason: CXCallEndedReason.answeredElsewhere) + break + case 5: + self.sharedProvider?.reportCall( + with: UUID(uuidString: uuid)!, endedAt: Date(), + reason: CXCallEndedReason.declinedElsewhere) + break + default: + break + } + } + + func endCallNotExist(_ data: CallData) { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(data.duration)) { + let call = self.callController.callWithUUID(uuid: UUID(uuidString: data.uuid)!) + if call != nil && self.answerCall == nil && self.outgoingCall == nil { + self.callEndTimeout(data) + } + } + } + + func callEndTimeout(_ data: CallData) { + self.saveEndCall(data.uuid, 3) + guard let call = self.callController.callWithUUID(uuid: UUID(uuidString: data.uuid)!) else { + return + } + sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_TIMEOUT, data.toJSON()) + } + + func getHandleType(_ handleType: String?) -> CXHandle.HandleType { + var typeDefault = CXHandle.HandleType.generic + switch handleType { + case "number": + typeDefault = CXHandle.HandleType.phoneNumber + break + case "email": + typeDefault = CXHandle.HandleType.emailAddress + default: + typeDefault = CXHandle.HandleType.generic + } + return typeDefault + } + + @objc public func initCallkitProvider(_ data: CallData) { + if self.sharedProvider == nil { + self.sharedProvider = CXProvider(configuration: createConfiguration(data)) + self.sharedProvider?.setDelegate(self, queue: nil) + } + self.callController.setSharedProvider(self.sharedProvider!) + } + + func createConfiguration(_ data: CallData) -> CXProviderConfiguration { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = data.supportsVideo + configuration.maximumCallGroups = data.maximumCallGroups + configuration.maximumCallsPerCallGroup = data.maximumCallsPerCallGroup + + configuration.supportedHandleTypes = [ + CXHandle.HandleType.generic, + CXHandle.HandleType.emailAddress, + CXHandle.HandleType.phoneNumber, + ] + if #available(iOS 11.0, *) { + configuration.includesCallsInRecents = data.includesCallsInRecents + } + if !data.iconName.isEmpty { + if let image = UIImage(named: data.iconName) { + configuration.iconTemplateImageData = image.pngData() + } else { + print("Unable to load icon \(data.iconName).") + } + } + if !data.ringtonePath.isEmpty && data.ringtonePath != "system_ringtone_default" { + configuration.ringtoneSound = data.ringtonePath + } + return configuration + } + + func sendDefaultAudioInterruptionNofificationToStartAudioResource() { + var userInfo: [AnyHashable: Any] = [:] + let intrepEndeRaw = AVAudioSession.InterruptionType.ended.rawValue + userInfo[AVAudioSessionInterruptionTypeKey] = intrepEndeRaw + userInfo[AVAudioSessionInterruptionOptionKey] = + AVAudioSession.InterruptionOptions.shouldResume.rawValue + NotificationCenter.default.post( + name: AVAudioSession.interruptionNotification, object: self, userInfo: userInfo) + } + + func configureAudioSession() { + if data?.configureAudioSession != false { + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory( + AVAudioSession.Category.playAndRecord, + options: [ + .allowBluetoothA2DP, + .duckOthers, + .allowBluetooth, + ]) + + try session.setMode(self.getAudioSessionMode(data?.audioSessionMode)) + try session.setActive(data?.audioSessionActive ?? true) + try session.setPreferredSampleRate(data?.audioSessionPreferredSampleRate ?? 44100.0) + try session.setPreferredIOBufferDuration( + data?.audioSessionPreferredIOBufferDuration ?? 0.005) + } catch { + print(error) + } + } + } + + func getAudioSessionMode(_ audioSessionMode: String?) -> AVAudioSession.Mode { + var mode = AVAudioSession.Mode.default + switch audioSessionMode { + case "gameChat": + mode = AVAudioSession.Mode.gameChat + break + case "measurement": + mode = AVAudioSession.Mode.measurement + break + case "moviePlayback": + mode = AVAudioSession.Mode.moviePlayback + break + case "spokenAudio": + mode = AVAudioSession.Mode.spokenAudio + break + case "videoChat": + mode = AVAudioSession.Mode.videoChat + break + case "videoRecording": + mode = AVAudioSession.Mode.videoRecording + break + case "voiceChat": + mode = AVAudioSession.Mode.voiceChat + break + case "voicePrompt": + if #available(iOS 12.0, *) { + mode = AVAudioSession.Mode.voicePrompt + } else { + // Fallback on earlier versions + } + break + default: + mode = AVAudioSession.Mode.default + } + return mode + } + + //MARK: - CXProviderDelegate + public func providerDidReset(_ provider: CXProvider) { + for call in self.callController.calls { + call.endCall() + } + self.callController.removeAllCalls() + } + + public func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + let call = Call(uuid: action.callUUID, data: self.data!, isOutGoing: true) + call.handle = action.handle.value + configureAudioSession() + call.hasStartedConnectDidChange = { [weak self] in + self?.sharedProvider?.reportOutgoingCall( + with: call.uuid, startedConnectingAt: call.connectData) + } + call.hasConnectDidChange = { [weak self] in + self?.sharedProvider?.reportOutgoingCall( + with: call.uuid, connectedAt: call.connectedData) + } + self.outgoingCall = call + self.callController.addCall(call) + self.sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_START, self.data?.toJSON()) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + guard let call = self.callController.callWithUUID(uuid: action.callUUID) else { + action.fail() + return + } + self.configureAudioSession() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1200)) { + self.configureAudioSession() + } + + call.hasConnectDidChange = { [weak self] in + self?.sharedProvider?.reportOutgoingCall( + with: call.uuid, connectedAt: call.connectedData) + } + self.data?.isAccepted = true + self.answerCall = call + sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_ACCEPT, self.data?.toJSON()) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + guard let call = self.callController.callWithUUID(uuid: action.callUUID) else { + if self.answerCall == nil && self.outgoingCall == nil { + sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_TIMEOUT, self.data?.toJSON()) + } else { + sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_ENDED, self.data?.toJSON()) + } + action.fail() + return + } + call.endCall() + self.callController.removeCall(call) + if self.answerCall == nil && self.outgoingCall == nil { + sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_DECLINE, self.data?.toJSON()) + action.fulfill() + } else { + self.answerCall = nil + sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_ENDED, call.data.toJSON()) + action.fulfill() + } + } + + public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { + guard let call = self.callController.callWithUUID(uuid: action.callUUID) else { + action.fail() + return + } + call.isOnHold = action.isOnHold + call.isMuted = action.isOnHold + self.callController.setHold(call: call, onHold: action.isOnHold) + sendHoldEvent(action.callUUID.uuidString, action.isOnHold) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + guard let call = self.callController.callWithUUID(uuid: action.callUUID) else { + action.fail() + return + } + call.isMuted = action.isMuted + sendMuteEvent(action.callUUID.uuidString, action.isMuted) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXSetGroupCallAction) { + guard (self.callController.callWithUUID(uuid: action.callUUID)) != nil else { + action.fail() + return + } + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_TOGGLE_GROUP, + [ + "id": action.callUUID.uuidString, + "callUUIDToGroupWith": action.callUUIDToGroupWith?.uuidString, + ]) + action.fulfill() + } + + public func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) { + guard (self.callController.callWithUUID(uuid: action.callUUID)) != nil else { + action.fail() + return + } + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_TOGGLE_DTMF, + [ + "id": action.callUUID.uuidString, "digits": action.digits, + "type": action.type.rawValue, + ]) + action.fulfill() + } + + public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { + guard let call = self.callController.callWithUUID(uuid: action.uuid) else { + action.fail() + return + } + sendEvent(StreamVideoIncomingCallConstants.ACTION_CALL_TIMEOUT, self.data?.toJSON()) + action.fulfill() + } + + public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + if self.answerCall?.hasConnected ?? false { + sendDefaultAudioInterruptionNofificationToStartAudioResource() + return + } + if self.outgoingCall?.hasConnected ?? false { + sendDefaultAudioInterruptionNofificationToStartAudioResource() + return + } + self.outgoingCall?.startCall(withAudioSession: audioSession) { success in + if success { + self.callController.addCall(self.outgoingCall!) + self.outgoingCall?.startAudio() + } + } + self.answerCall?.answerCall(withAudioSession: audioSession) { success in + if success { + self.answerCall?.startAudio() + } + } + sendDefaultAudioInterruptionNofificationToStartAudioResource() + configureAudioSession() + + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_TOGGLE_AUDIO_SESSION, ["isActive": true] + ) + } + + public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + if self.outgoingCall?.isOnHold ?? false || self.answerCall?.isOnHold ?? false { + print("Call is on hold") + return + } + + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_TOGGLE_AUDIO_SESSION, + ["isActive": false]) + } + + private func sendMuteEvent(_ id: String, _ isMuted: Bool) { + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_TOGGLE_MUTE, + ["id": id, "isMuted": isMuted]) + } + + private func sendHoldEvent(_ id: String, _ isOnHold: Bool) { + self.sendEvent( + StreamVideoIncomingCallConstants.ACTION_CALL_TOGGLE_HOLD, + ["id": id, "isOnHold": isOnHold]) + } + +} diff --git a/packages/stream_video_push_notification/ios/Classes/StreamVideoIncomingCallConstants.swift b/packages/stream_video_push_notification/ios/Classes/StreamVideoIncomingCallConstants.swift new file mode 100644 index 000000000..215bc1763 --- /dev/null +++ b/packages/stream_video_push_notification/ios/Classes/StreamVideoIncomingCallConstants.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Constants for Stream Video Ringing actions and events +@available(iOS 10.0, *) +public struct StreamVideoIncomingCallConstants { + + // MARK: - Push Token Events + public static let ACTION_DID_UPDATE_DEVICE_PUSH_TOKEN_VOIP = + "io.getstream.video.DID_UPDATE_DEVICE_PUSH_TOKEN_VOIP" + + // MARK: - Call Events + public static let ACTION_CALL_INCOMING = "io.getstream.video.ACTION_CALL_INCOMING" + public static let ACTION_CALL_START = "io.getstream.video.ACTION_CALL_START" + public static let ACTION_CALL_ACCEPT = "io.getstream.video.ACTION_CALL_ACCEPT" + public static let ACTION_CALL_DECLINE = "io.getstream.video.ACTION_CALL_DECLINE" + public static let ACTION_CALL_ENDED = "io.getstream.video.ACTION_CALL_ENDED" + public static let ACTION_CALL_TIMEOUT = "io.getstream.video.ACTION_CALL_TIMEOUT" + public static let ACTION_CALL_CUSTOM = "io.getstream.video.ACTION_CALL_CUSTOM" + public static let ACTION_CALL_CONNECTED = "io.getstream.video.ACTION_CALL_CONNECTED" + + // MARK: - Call Control Events + public static let ACTION_CALL_TOGGLE_HOLD = "io.getstream.video.ACTION_CALL_TOGGLE_HOLD" + public static let ACTION_CALL_TOGGLE_MUTE = "io.getstream.video.ACTION_CALL_TOGGLE_MUTE" + public static let ACTION_CALL_TOGGLE_DTMF = "io.getstream.video.ACTION_CALL_TOGGLE_DTMF" + public static let ACTION_CALL_TOGGLE_GROUP = "io.getstream.video.ACTION_CALL_TOGGLE_GROUP" + public static let ACTION_CALL_TOGGLE_AUDIO_SESSION = + "io.getstream.video.ACTION_CALL_TOGGLE_AUDIO_SESSION" +} diff --git a/packages/stream_video_push_notification/ios/Classes/StreamVideoPKDelegateManager.swift b/packages/stream_video_push_notification/ios/Classes/StreamVideoPKDelegateManager.swift index b222eb6e1..abd0a95fc 100644 --- a/packages/stream_video_push_notification/ios/Classes/StreamVideoPKDelegateManager.swift +++ b/packages/stream_video_push_notification/ios/Classes/StreamVideoPKDelegateManager.swift @@ -1,7 +1,6 @@ import Flutter import Foundation import PushKit -import flutter_callkit_incoming public class StreamVideoPKDelegateManager: NSObject, PKPushRegistryDelegate, UNUserNotificationCenterDelegate @@ -9,7 +8,7 @@ public class StreamVideoPKDelegateManager: NSObject, PKPushRegistryDelegate, public static let shared = StreamVideoPKDelegateManager() private var pushRegistry: PKPushRegistry? - private var defaultData: [String: Any]? + private var defaultConfiguration: StreamVideoPushConfiguration? private var mainChannel: FlutterMethodChannel? private override init() { @@ -27,7 +26,7 @@ public class StreamVideoPKDelegateManager: NSObject, PKPushRegistryDelegate, } public func initData(data: [String: Any]) { - defaultData = data + defaultConfiguration = StreamVideoPushConfiguration(args: data) } // MARK: - PKPushRegistryDelegate @@ -55,21 +54,9 @@ public class StreamVideoPKDelegateManager: NSObject, PKPushRegistryDelegate, } var streamDict = payload.dictionaryPayload["stream"] as? [String: Any] - - let state = UIApplication.shared.applicationState - if state == .background || state == .inactive { - handleIncomingCall(streamDict: streamDict, state: state, completion: completion) - } else if state == .active { - mainChannel?.invokeMethod("customizeCaller", arguments: streamDict) { (response) in - if let customData = response as? [String: Any] { - streamDict?["created_by_display_name"] = customData["name"] as? String - streamDict?["created_by_id"] = customData["handle"] as? String - } - - self.handleIncomingCall( - streamDict: streamDict, state: state, completion: completion) - } - } + handleIncomingCall( + streamDict: streamDict, state: UIApplication.shared.applicationState, + completion: completion) } func handleIncomingCall( @@ -86,29 +73,30 @@ public class StreamVideoPKDelegateManager: NSObject, PKPushRegistryDelegate, var callUUID = UUID().uuidString - let data: StreamVideoPushParams - if let jsonData = self.defaultData { - data = StreamVideoPushParams(args: jsonData) + let data: CallData + if let configuration = self.defaultConfiguration { + data = CallData.init(args: configuration.toJSON()) } else { - data = StreamVideoPushParams(args: [String: Any]()) + data = CallData.init(args: [String: Any]()) } let nonEmptyString: (String?) -> String? = { str in return str?.isEmpty == false ? str : nil } - data.callKitData.uuid = callUUID - data.callKitData.nameCaller = + data.uuid = callUUID + data.callerName = nonEmptyString(callDisplayName) ?? nonEmptyString(createdByName) ?? defaultCallText - data.callKitData.handle = createdById ?? defaultCallText - data.callKitData.type = videoData - data.callKitData.extra = ["callCid": callCid] - data.callKitData.iconName = - UserDefaults.standard.string(forKey: "callKit_iconName") ?? data.callKitData.iconName + data.handle = createdById ?? defaultCallText + data.type = videoData + data.extra = ["callCid": callCid] + data.iconName = + UserDefaults.standard.string(forKey: "callKit_iconName") ?? defaultConfiguration? + .iconName ?? data.iconName // Show call incoming notification. StreamVideoPushNotificationPlugin.showIncomingCall( - data: data.callKitData, + data: data, fromPushKit: true ) diff --git a/packages/stream_video_push_notification/ios/Classes/StreamVideoPushConfiguration.swift b/packages/stream_video_push_notification/ios/Classes/StreamVideoPushConfiguration.swift new file mode 100644 index 000000000..6825c0f22 --- /dev/null +++ b/packages/stream_video_push_notification/ios/Classes/StreamVideoPushConfiguration.swift @@ -0,0 +1,129 @@ +import Foundation + +@objc public class StreamVideoPushConfiguration: NSObject { + @objc public var headers: NSDictionary + + // flattened iOS params + @objc public var iconName: String + @objc public var handleType: String + @objc public var useComplexHandle: Bool + @objc public var supportsVideo: Bool + @objc public var maximumCallGroups: Int + @objc public var maximumCallsPerCallGroup: Int + @objc public var audioSessionMode: String + @objc public var audioSessionActive: Bool + @objc public var audioSessionPreferredSampleRate: Double + @objc public var audioSessionPreferredIOBufferDuration: Double + @objc public var configureAudioSession: Bool + @objc public var supportsDTMF: Bool + @objc public var supportsHolding: Bool + @objc public var supportsGrouping: Bool + @objc public var supportsUngrouping: Bool + @objc public var ringtonePath: String + + @objc public init(headers: NSDictionary) { + self.headers = headers + + // Default iOS values + self.iconName = "CallKitLogo" + self.handleType = "" + self.useComplexHandle = false + self.supportsVideo = true + self.maximumCallGroups = 2 + self.maximumCallsPerCallGroup = 1 + self.audioSessionMode = "" + self.audioSessionActive = true + self.audioSessionPreferredSampleRate = 44100.0 + self.audioSessionPreferredIOBufferDuration = 0.005 + self.configureAudioSession = true + self.supportsDTMF = true + self.supportsHolding = true + self.supportsGrouping = true + self.supportsUngrouping = true + self.ringtonePath = "" + } + + @objc public convenience init(args: NSDictionary) { + + var argsConvert = [String: Any?]() + for (key, value) in args { + if let keyString = key as? String { + argsConvert[keyString] = value + } + } + self.init(args: argsConvert) + } + + public init(args: [String: Any?]) { + self.headers = args["headers"] as? NSDictionary ?? [:] + + if let ios = args["ios"] as? [String: Any] { + self.iconName = ios["iconName"] as? String ?? "CallKitLogo" + self.handleType = ios["handleType"] as? String ?? "" + self.useComplexHandle = ios["useComplexHandle"] as? Bool ?? false + self.supportsVideo = ios["supportsVideo"] as? Bool ?? true + self.maximumCallGroups = ios["maximumCallGroups"] as? Int ?? 2 + self.maximumCallsPerCallGroup = ios["maximumCallsPerCallGroup"] as? Int ?? 1 + self.audioSessionMode = ios["audioSessionMode"] as? String ?? "" + self.audioSessionActive = ios["audioSessionActive"] as? Bool ?? true + self.audioSessionPreferredSampleRate = + ios["audioSessionPreferredSampleRate"] as? Double ?? 44100.0 + self.audioSessionPreferredIOBufferDuration = + ios["audioSessionPreferredIOBufferDuration"] as? Double ?? 0.005 + self.configureAudioSession = ios["configureAudioSession"] as? Bool ?? true + self.supportsDTMF = ios["supportsDTMF"] as? Bool ?? true + self.supportsHolding = ios["supportsHolding"] as? Bool ?? true + self.supportsGrouping = ios["supportsGrouping"] as? Bool ?? true + self.supportsUngrouping = ios["supportsUngrouping"] as? Bool ?? true + self.ringtonePath = ios["ringtonePath"] as? String ?? "" + } else { + // Fallback to top-level properties if ios object doesn't exist + self.iconName = args["iconName"] as? String ?? "CallKitLogo" + self.handleType = args["handleType"] as? String ?? "" + self.useComplexHandle = args["useComplexHandle"] as? Bool ?? false + self.supportsVideo = args["supportsVideo"] as? Bool ?? true + self.maximumCallGroups = args["maximumCallGroups"] as? Int ?? 2 + self.maximumCallsPerCallGroup = args["maximumCallsPerCallGroup"] as? Int ?? 1 + self.audioSessionMode = args["audioSessionMode"] as? String ?? "" + self.audioSessionActive = args["audioSessionActive"] as? Bool ?? true + self.audioSessionPreferredSampleRate = + args["audioSessionPreferredSampleRate"] as? Double ?? 44100.0 + self.audioSessionPreferredIOBufferDuration = + args["audioSessionPreferredIOBufferDuration"] as? Double ?? 0.005 + self.configureAudioSession = args["configureAudioSession"] as? Bool ?? true + self.supportsDTMF = args["supportsDTMF"] as? Bool ?? true + self.supportsHolding = args["supportsHolding"] as? Bool ?? true + self.supportsGrouping = args["supportsGrouping"] as? Bool ?? true + self.supportsUngrouping = args["supportsUngrouping"] as? Bool ?? true + self.ringtonePath = args["ringtonePath"] as? String ?? "" + } + } + + open func toJSON() -> [String: Any] { + let ios: [String: Any] = [ + "iconName": iconName, + "handleType": handleType, + "useComplexHandle": useComplexHandle, + "supportsVideo": supportsVideo, + "maximumCallGroups": maximumCallGroups, + "maximumCallsPerCallGroup": maximumCallsPerCallGroup, + "audioSessionMode": audioSessionMode, + "audioSessionActive": audioSessionActive, + "audioSessionPreferredSampleRate": audioSessionPreferredSampleRate, + "audioSessionPreferredIOBufferDuration": audioSessionPreferredIOBufferDuration, + "configureAudioSession": configureAudioSession, + "supportsDTMF": supportsDTMF, + "supportsHolding": supportsHolding, + "supportsGrouping": supportsGrouping, + "supportsUngrouping": supportsUngrouping, + "ringtonePath": ringtonePath, + ] + + let result: [String: Any] = [ + "headers": headers, + "ios": ios, + ] + + return result + } +} diff --git a/packages/stream_video_push_notification/ios/Classes/StreamVideoPushNotificationPlugin.swift b/packages/stream_video_push_notification/ios/Classes/StreamVideoPushNotificationPlugin.swift index d69d713f5..98899b123 100644 --- a/packages/stream_video_push_notification/ios/Classes/StreamVideoPushNotificationPlugin.swift +++ b/packages/stream_video_push_notification/ios/Classes/StreamVideoPushNotificationPlugin.swift @@ -1,16 +1,34 @@ import Flutter import UIKit -import flutter_callkit_incoming public class StreamVideoPushNotificationPlugin: NSObject, FlutterPlugin { + private let devicePushTokenVoIP = "DevicePushTokenVoIP" + let persistentState: UserDefaults = UserDefaults.standard + @objc public private(set) static var sharedInstance: StreamVideoPushNotificationPlugin! + + private var callKitManager: StreamVideoCallkitManager + + public init(callKitManager: StreamVideoCallkitManager) { + self.callKitManager = callKitManager + super.init() + } + public static func register(with registrar: FlutterPluginRegistrar) { let mainChannel = FlutterMethodChannel( name: "stream_video_push_notification", binaryMessenger: registrar.messenger()) - let instance = StreamVideoPushNotificationPlugin() + let eventChannel = FlutterEventChannel( + name: "stream_video_push_notification_events", binaryMessenger: registrar.messenger()) + + let eventsHandler = EventCallbackHandler() + eventChannel.setStreamHandler(eventsHandler) + + let callKitManager = StreamVideoCallkitManager(eventHandler: eventsHandler) + sharedInstance = StreamVideoPushNotificationPlugin(callKitManager: callKitManager) + + registrar.addMethodCallDelegate(sharedInstance, channel: mainChannel) - registrar.addMethodCallDelegate(instance, channel: mainChannel) StreamVideoPKDelegateManager.shared.initChannel(mainChannel: mainChannel) } @@ -25,37 +43,79 @@ public class StreamVideoPushNotificationPlugin: NSObject, FlutterPlugin { } StreamVideoPKDelegateManager.shared.initData(data: arguments) + callKitManager.initCallkitProvider(CallData(args: arguments)) result(nil) } else { result( FlutterError( code: "INVALID_ARGUMENT", message: "Invalid argument", details: nil)) } + case "getDevicePushTokenVoIP": + result(self.getDevicePushTokenVoIP()) + break default: - result(FlutterMethodNotImplemented) + callKitManager.handle(call, result: result) } } @objc public static func setDevicePushTokenVoIP(deviceToken: String) { - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken) + sharedInstance.setDevicePushTokenVoIP(deviceToken: deviceToken) + //TODO: send event? //ACTION_DID_UPDATE_DEVICE_PUSH_TOKEN_VOIP } @objc public static func startOutgoingCall( - data: flutter_callkit_incoming.Data, + data: CallData, fromPushKit: Bool ) { - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.startCall(data, fromPushKit: fromPushKit) + sharedInstance.callKitManager.startCall(data, fromPushKit: fromPushKit) } @objc public static func showIncomingCall( - data: flutter_callkit_incoming.Data, + data: CallData, fromPushKit: Bool ) { - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( + sharedInstance.callKitManager.showIncomingCall( data, fromPushKit: fromPushKit) } @objc public static func activeCalls() -> [[String: Any]]? { - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() + sharedInstance.callKitManager.activeCalls() + } + + @objc public func setDevicePushTokenVoIP(deviceToken: String) { + persistentState.set(deviceToken, forKey: devicePushTokenVoIP) + } + + @objc public func getDevicePushTokenVoIP() -> String { + return persistentState.string(forKey: devicePushTokenVoIP) ?? "" + } +} + +public class EventCallbackHandler: NSObject, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + + public func send(_ event: String, _ body: Any) { + let data: [String: Any] = [ + "event": event, + "body": body, + ] + + DispatchQueue.main.async { [weak self] in + self?.eventSink?(data) + } + } + + public func onListen( + withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink + ) + -> FlutterError? + { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil } } diff --git a/packages/stream_video_push_notification/ios/Classes/StreamVideoPushParams.swift b/packages/stream_video_push_notification/ios/Classes/StreamVideoPushParams.swift deleted file mode 100644 index cdba9b4a3..000000000 --- a/packages/stream_video_push_notification/ios/Classes/StreamVideoPushParams.swift +++ /dev/null @@ -1,14 +0,0 @@ -import flutter_callkit_incoming - -@objc public class StreamVideoPushParams: NSObject { - public var callKitData: flutter_callkit_incoming.Data; - - public init(args: [String: Any?]) { - self.callKitData = flutter_callkit_incoming.Data.init(args: args) - } - - public func toJSON() -> [String: Any] { - var map = callKitData.toJSON() - return map - } -} diff --git a/packages/stream_video_push_notification/ios/Classes/StringUtils.swift b/packages/stream_video_push_notification/ios/Classes/StringUtils.swift new file mode 100644 index 000000000..e7d7344d7 --- /dev/null +++ b/packages/stream_video_push_notification/ios/Classes/StringUtils.swift @@ -0,0 +1,95 @@ +import CryptoSwift +import Foundation + +extension String { + + func encrypt( + encryptionKey: String = "xrBixqjjMhHifSDgSJ8O4QJYMZ1UHs45", iv: String = "lmYSgP3vixDAiBzW" + ) -> String { + if let aes = try? AES(key: encryptionKey, iv: iv), + let encrypted = try? aes.encrypt([UInt8](self.utf8)) + { + return encrypted.toHexString() + } + return "" + } + + func decrypt( + encryptionKey: String = "xrBixqjjMhHifSDgSJ8O4QJYMZ1UHs45", iv: String = "lmYSgP3vixDAiBzW" + ) -> String { + if let aes = try? AES(key: encryptionKey, iv: iv), + let decrypted = try? aes.decrypt([UInt8](hex: self)) + { + return String(data: Foundation.Data(decrypted), encoding: .utf8) ?? "" + } + return "" + } + + func fromBase64() -> String { + guard let data = Foundation.Data(base64Encoded: self) else { + return "" + } + return String(data: data, encoding: .utf8) ?? "" + } + + func toBase64() -> String { + return Foundation.Data(self.utf8).base64EncodedString() + } + + public func encryptHandle( + encryptionKey: String = "xrBixqjjMhHifSDgSJ8O4QJYMZ1UHs45", iv: String = "lmYSgP3vixDAiBzW" + ) -> String { + return self.encrypt(encryptionKey: encryptionKey, iv: iv).toBase64() + } + + public func decryptHandle( + encryptionKey: String = "xrBixqjjMhHifSDgSJ8O4QJYMZ1UHs45", iv: String = "lmYSgP3vixDAiBzW" + ) -> String { + return self.fromBase64().decrypt(encryptionKey: encryptionKey, iv: iv) + } + + public func getDecryptHandle() -> [String: Any] { + if !self.isBase64Encoded() { + var map: [String: Any] = [:] + map["handle"] = self + return map + } + if let data = self.decryptHandle().data(using: .utf8) { + do { + return try + (JSONSerialization.jsonObject(with: data, options: []) as? [String: Any])! + } catch { + print(error.localizedDescription) + } + } + return [:] + } + + public func getHandleType() -> String { + if !self.isBase64Encoded() { + if !self.isPhoneNumber() { + return "email" + } else { + return "number" + } + } + return "generic" + } + + public func isBase64Encoded() -> Bool { + let value = self.fromBase64() + return !value.isEmpty + } + + func isPhoneNumber() -> Bool { + let cleanedValue = + self + .replacingOccurrences(of: "[+-]", with: "", options: .regularExpression) + .replacingOccurrences(of: "[ ]", with: "", options: .regularExpression) + + let decimalCharacters = CharacterSet.decimalDigits + let characterSet = CharacterSet(charactersIn: cleanedValue) + return decimalCharacters.isSuperset(of: characterSet) + } + +} diff --git a/packages/stream_video_push_notification/ios/stream_video_push_notification.podspec b/packages/stream_video_push_notification/ios/stream_video_push_notification.podspec index 0b22838b3..b25a0f97d 100644 --- a/packages/stream_video_push_notification/ios/stream_video_push_notification.podspec +++ b/packages/stream_video_push_notification/ios/stream_video_push_notification.podspec @@ -15,8 +15,8 @@ Official Push Notification Plugin for Stream Video. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.dependency 'flutter_callkit_incoming' - s.platform = :ios, '15' + s.dependency 'CryptoSwift', '~> 1.8' + s.platform = :ios, '14' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/packages/stream_video_push_notification/lib/src/stream_video_call_event.dart b/packages/stream_video_push_notification/lib/src/stream_video_call_event.dart new file mode 100644 index 000000000..75d47720b --- /dev/null +++ b/packages/stream_video_push_notification/lib/src/stream_video_call_event.dart @@ -0,0 +1,66 @@ +/// Object CallEvent. +class CallEvent { + CallEvent(this.body, this.event); + + final Event event; + final dynamic body; + + @override + String toString() => 'CallEvent( body: $body, event: $event)'; +} + +enum Event { + actionDidUpdateDevicePushTokenVoip, + actionCallIncoming, + actionCallStart, + actionCallAccept, + actionCallDecline, + actionCallEnded, + actionCallTimeout, + actionCallConnected, + actionCallCallback, + actionCallToggleHold, + actionCallToggleMute, + actionCallToggleDtmf, + actionCallToggleGroup, + actionCallToggleAudioSession, + actionCallCustom, +} + +/// Using extension for backward compatibility Dart SDK 2.17.0 and lower +extension EventX on Event { + String get name { + switch (this) { + case Event.actionDidUpdateDevicePushTokenVoip: + return 'io.getstream.video.DID_UPDATE_DEVICE_PUSH_TOKEN_VOIP'; + case Event.actionCallIncoming: + return 'io.getstream.video.ACTION_CALL_INCOMING'; + case Event.actionCallStart: + return 'io.getstream.video.ACTION_CALL_START'; + case Event.actionCallAccept: + return 'io.getstream.video.ACTION_CALL_ACCEPT'; + case Event.actionCallDecline: + return 'io.getstream.video.ACTION_CALL_DECLINE'; + case Event.actionCallEnded: + return 'io.getstream.video.ACTION_CALL_ENDED'; + case Event.actionCallTimeout: + return 'io.getstream.video.ACTION_CALL_TIMEOUT'; + case Event.actionCallConnected: + return 'io.getstream.video.ACTION_CALL_CONNECTED'; + case Event.actionCallCallback: + return 'io.getstream.video.ACTION_CALL_CALLBACK'; + case Event.actionCallToggleHold: + return 'io.getstream.video.ACTION_CALL_TOGGLE_HOLD'; + case Event.actionCallToggleMute: + return 'io.getstream.video.ACTION_CALL_TOGGLE_MUTE'; + case Event.actionCallToggleDtmf: + return 'io.getstream.video.ACTION_CALL_TOGGLE_DTMF'; + case Event.actionCallToggleGroup: + return 'io.getstream.video.ACTION_CALL_TOGGLE_GROUP'; + case Event.actionCallToggleAudioSession: + return 'io.getstream.video.ACTION_CALL_TOGGLE_AUDIO_SESSION'; + case Event.actionCallCustom: + return 'io.getstream.video.ACTION_CALL_CUSTOM'; + } + } +} diff --git a/packages/stream_video_push_notification/lib/src/stream_video_push_configuration.dart b/packages/stream_video_push_notification/lib/src/stream_video_push_configuration.dart new file mode 100644 index 000000000..2f1f64a8f --- /dev/null +++ b/packages/stream_video_push_notification/lib/src/stream_video_push_configuration.dart @@ -0,0 +1,249 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../stream_video_push_notification.dart'; + +part 'stream_video_push_configuration.g.dart'; + +@JsonSerializable(explicitToJson: true) +class StreamVideoPushConfiguration { + const StreamVideoPushConfiguration({ + this.headers, + this.android, + this.ios, + }); + + factory StreamVideoPushConfiguration.fromJson(Map json) => + _$StreamVideoPushConfigurationFromJson(json); + + final Map? headers; + final AndroidPushConfiguration? android; + final IOSPushConfiguration? ios; + + StreamVideoPushConfiguration copyWith({ + Map? headers, + AndroidPushConfiguration? android, + IOSPushConfiguration? ios, + }) { + return StreamVideoPushConfiguration( + headers: headers ?? this.headers, + android: android ?? this.android, + ios: ios ?? this.ios, + ); + } + + StreamVideoPushConfiguration merge(StreamVideoPushConfiguration? other) { + if (other == null) return this; + + final mergedHeaders = { + if (headers != null) ...headers!, + if (other.headers != null) ...other.headers!, + }; + + return StreamVideoPushConfiguration( + headers: mergedHeaders.isEmpty ? null : mergedHeaders, + android: other.android != null + ? (android?.merge(other.android) ?? other.android) + : android, + ios: other.ios != null ? (ios?.merge(other.ios) ?? other.ios) : ios, + ); + } + + Map toJson() => _$StreamVideoPushConfigurationToJson(this); +} + +/// Object config for Android. +@JsonSerializable(explicitToJson: true) +class AndroidPushConfiguration { + const AndroidPushConfiguration({ + this.missedCallNotification, + this.incomingCallNotification, + this.defaultAvatar, + this.ringtonePath, + this.incomingCallNotificationChannelName, + this.missedCallNotificationChannelName, + this.showFullScreenOnLockScreen, + }); + + factory AndroidPushConfiguration.fromJson(Map json) => + _$AndroidPushConfigurationFromJson(json); + + final MissedCallNotificationParams? missedCallNotification; + + final IncomingCallNotificationParams? incomingCallNotification; + + /// Default avatar for call, example: http://... https://... or "assets/abc.png" + final String? defaultAvatar; + + /// File name ringtone, put file into /android/app/src/main/res/raw/ringtone_default.mp3 -> value: `ringtone_default` + final String? ringtonePath; + + /// Notification channel name of incoming call. + final String? incomingCallNotificationChannelName; + + /// Notification channel name of missed call. + final String? missedCallNotificationChannelName; + + /// Show full locked screen. + final bool? showFullScreenOnLockScreen; + + AndroidPushConfiguration copyWith({ + MissedCallNotificationParams? missedCallNotification, + IncomingCallNotificationParams? incomingCallNotification, + String? defaultAvatar, + String? ringtonePath, + String? incomingCallNotificationChannelName, + String? missedCallNotificationChannelName, + bool? showFullScreenOnLockScreen, + }) { + return AndroidPushConfiguration( + missedCallNotification: + missedCallNotification ?? this.missedCallNotification, + incomingCallNotification: + incomingCallNotification ?? this.incomingCallNotification, + defaultAvatar: defaultAvatar ?? this.defaultAvatar, + ringtonePath: ringtonePath ?? this.ringtonePath, + incomingCallNotificationChannelName: + incomingCallNotificationChannelName ?? + this.incomingCallNotificationChannelName, + missedCallNotificationChannelName: + missedCallNotificationChannelName ?? + this.missedCallNotificationChannelName, + showFullScreenOnLockScreen: + showFullScreenOnLockScreen ?? this.showFullScreenOnLockScreen, + ); + } + + AndroidPushConfiguration merge(AndroidPushConfiguration? other) { + if (other == null) return this; + + return copyWith( + missedCallNotification: other.missedCallNotification, + incomingCallNotification: other.incomingCallNotification, + defaultAvatar: other.defaultAvatar, + ringtonePath: other.ringtonePath, + incomingCallNotificationChannelName: + other.incomingCallNotificationChannelName, + missedCallNotificationChannelName: + other.missedCallNotificationChannelName, + showFullScreenOnLockScreen: other.showFullScreenOnLockScreen, + ); + } + + Map toJson() => _$AndroidPushConfigurationToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class IOSPushConfiguration { + const IOSPushConfiguration({ + this.iconName, + this.handleType, + this.useComplexHandle, + this.supportsVideo, + this.maximumCallGroups, + this.maximumCallsPerCallGroup, + this.audioSessionMode, + this.audioSessionActive, + this.audioSessionPreferredSampleRate, + this.audioSessionPreferredIOBufferDuration, + this.configureAudioSession, + this.supportsDTMF, + this.supportsHolding, + this.supportsGrouping, + this.supportsUngrouping, + this.ringtonePath, + }); + + factory IOSPushConfiguration.fromJson(Map json) => + _$IOSPushConfigurationFromJson(json); + + /// App's Icon. using for display inside Callkit(iOS) + final String? iconName; + + /// Type handle call `generic`, `number`, `email` + final String? handleType; + final bool? useComplexHandle; + final bool? supportsVideo; + final int? maximumCallGroups; + final int? maximumCallsPerCallGroup; + final String? audioSessionMode; + final bool? audioSessionActive; + final double? audioSessionPreferredSampleRate; + final double? audioSessionPreferredIOBufferDuration; + final bool? configureAudioSession; + final bool? supportsDTMF; + final bool? supportsHolding; + final bool? supportsGrouping; + final bool? supportsUngrouping; + + /// Add file to root project xcode /ios/Runner/Ringtone.caf and Copy Bundle Resources(Build Phases) -> value: "Ringtone.caf" + final String? ringtonePath; + + IOSPushConfiguration copyWith({ + String? iconName, + String? handleType, + bool? useComplexHandle, + bool? supportsVideo, + int? maximumCallGroups, + int? maximumCallsPerCallGroup, + String? audioSessionMode, + bool? audioSessionActive, + double? audioSessionPreferredSampleRate, + double? audioSessionPreferredIOBufferDuration, + bool? configureAudioSession, + bool? supportsDTMF, + bool? supportsHolding, + bool? supportsGrouping, + bool? supportsUngrouping, + String? ringtonePath, + }) { + return IOSPushConfiguration( + iconName: iconName ?? this.iconName, + handleType: handleType ?? this.handleType, + useComplexHandle: useComplexHandle ?? this.useComplexHandle, + supportsVideo: supportsVideo ?? this.supportsVideo, + maximumCallGroups: maximumCallGroups ?? this.maximumCallGroups, + maximumCallsPerCallGroup: + maximumCallsPerCallGroup ?? this.maximumCallsPerCallGroup, + audioSessionMode: audioSessionMode ?? this.audioSessionMode, + audioSessionActive: audioSessionActive ?? this.audioSessionActive, + audioSessionPreferredSampleRate: + audioSessionPreferredSampleRate ?? + this.audioSessionPreferredSampleRate, + audioSessionPreferredIOBufferDuration: + audioSessionPreferredIOBufferDuration ?? + this.audioSessionPreferredIOBufferDuration, + configureAudioSession: + configureAudioSession ?? this.configureAudioSession, + supportsDTMF: supportsDTMF ?? this.supportsDTMF, + supportsHolding: supportsHolding ?? this.supportsHolding, + supportsGrouping: supportsGrouping ?? this.supportsGrouping, + supportsUngrouping: supportsUngrouping ?? this.supportsUngrouping, + ringtonePath: ringtonePath ?? this.ringtonePath, + ); + } + + IOSPushConfiguration merge(IOSPushConfiguration? other) { + if (other == null) return this; + + return copyWith( + iconName: other.iconName, + handleType: other.handleType, + useComplexHandle: other.useComplexHandle, + supportsVideo: other.supportsVideo, + maximumCallGroups: other.maximumCallGroups, + maximumCallsPerCallGroup: other.maximumCallsPerCallGroup, + audioSessionMode: other.audioSessionMode, + audioSessionActive: other.audioSessionActive, + audioSessionPreferredSampleRate: other.audioSessionPreferredSampleRate, + audioSessionPreferredIOBufferDuration: + other.audioSessionPreferredIOBufferDuration, + configureAudioSession: other.configureAudioSession, + supportsDTMF: other.supportsDTMF, + supportsHolding: other.supportsHolding, + supportsGrouping: other.supportsGrouping, + supportsUngrouping: other.supportsUngrouping, + ringtonePath: other.ringtonePath, + ); + } + + Map toJson() => _$IOSPushConfigurationToJson(this); +} diff --git a/packages/stream_video_push_notification/lib/src/stream_video_push_configuration.g.dart b/packages/stream_video_push_notification/lib/src/stream_video_push_configuration.g.dart new file mode 100644 index 000000000..7cb2e38fe --- /dev/null +++ b/packages/stream_video_push_notification/lib/src/stream_video_push_configuration.g.dart @@ -0,0 +1,110 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stream_video_push_configuration.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StreamVideoPushConfiguration _$StreamVideoPushConfigurationFromJson( + Map json, +) => StreamVideoPushConfiguration( + headers: json['headers'] as Map?, + android: json['android'] == null + ? null + : AndroidPushConfiguration.fromJson( + json['android'] as Map, + ), + ios: json['ios'] == null + ? null + : IOSPushConfiguration.fromJson(json['ios'] as Map), +); + +Map _$StreamVideoPushConfigurationToJson( + StreamVideoPushConfiguration instance, +) => { + 'headers': instance.headers, + 'android': instance.android?.toJson(), + 'ios': instance.ios?.toJson(), +}; + +AndroidPushConfiguration _$AndroidPushConfigurationFromJson( + Map json, +) => AndroidPushConfiguration( + missedCallNotification: json['missedCallNotification'] == null + ? null + : MissedCallNotificationParams.fromJson( + json['missedCallNotification'] as Map, + ), + incomingCallNotification: json['incomingCallNotification'] == null + ? null + : IncomingCallNotificationParams.fromJson( + json['incomingCallNotification'] as Map, + ), + defaultAvatar: json['defaultAvatar'] as String?, + ringtonePath: json['ringtonePath'] as String?, + incomingCallNotificationChannelName: + json['incomingCallNotificationChannelName'] as String?, + missedCallNotificationChannelName: + json['missedCallNotificationChannelName'] as String?, + showFullScreenOnLockScreen: json['showFullScreenOnLockScreen'] as bool?, +); + +Map _$AndroidPushConfigurationToJson( + AndroidPushConfiguration instance, +) => { + 'missedCallNotification': instance.missedCallNotification?.toJson(), + 'incomingCallNotification': instance.incomingCallNotification?.toJson(), + 'defaultAvatar': instance.defaultAvatar, + 'ringtonePath': instance.ringtonePath, + 'incomingCallNotificationChannelName': + instance.incomingCallNotificationChannelName, + 'missedCallNotificationChannelName': + instance.missedCallNotificationChannelName, + 'showFullScreenOnLockScreen': instance.showFullScreenOnLockScreen, +}; + +IOSPushConfiguration _$IOSPushConfigurationFromJson( + Map json, +) => IOSPushConfiguration( + iconName: json['iconName'] as String?, + handleType: json['handleType'] as String?, + useComplexHandle: json['useComplexHandle'] as bool?, + supportsVideo: json['supportsVideo'] as bool?, + maximumCallGroups: (json['maximumCallGroups'] as num?)?.toInt(), + maximumCallsPerCallGroup: (json['maximumCallsPerCallGroup'] as num?)?.toInt(), + audioSessionMode: json['audioSessionMode'] as String?, + audioSessionActive: json['audioSessionActive'] as bool?, + audioSessionPreferredSampleRate: + (json['audioSessionPreferredSampleRate'] as num?)?.toDouble(), + audioSessionPreferredIOBufferDuration: + (json['audioSessionPreferredIOBufferDuration'] as num?)?.toDouble(), + configureAudioSession: json['configureAudioSession'] as bool?, + supportsDTMF: json['supportsDTMF'] as bool?, + supportsHolding: json['supportsHolding'] as bool?, + supportsGrouping: json['supportsGrouping'] as bool?, + supportsUngrouping: json['supportsUngrouping'] as bool?, + ringtonePath: json['ringtonePath'] as String?, +); + +Map _$IOSPushConfigurationToJson( + IOSPushConfiguration instance, +) => { + 'iconName': instance.iconName, + 'handleType': instance.handleType, + 'useComplexHandle': instance.useComplexHandle, + 'supportsVideo': instance.supportsVideo, + 'maximumCallGroups': instance.maximumCallGroups, + 'maximumCallsPerCallGroup': instance.maximumCallsPerCallGroup, + 'audioSessionMode': instance.audioSessionMode, + 'audioSessionActive': instance.audioSessionActive, + 'audioSessionPreferredSampleRate': instance.audioSessionPreferredSampleRate, + 'audioSessionPreferredIOBufferDuration': + instance.audioSessionPreferredIOBufferDuration, + 'configureAudioSession': instance.configureAudioSession, + 'supportsDTMF': instance.supportsDTMF, + 'supportsHolding': instance.supportsHolding, + 'supportsGrouping': instance.supportsGrouping, + 'supportsUngrouping': instance.supportsUngrouping, + 'ringtonePath': instance.ringtonePath, +}; diff --git a/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart b/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart index e14eeb038..35b8c3acf 100644 --- a/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart +++ b/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart @@ -1,20 +1,19 @@ +// ignore_for_file: avoid_dynamic_calls + import 'dart:async'; import 'package:collection/collection.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter_callkit_incoming/entities/entities.dart'; -import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:rxdart/rxdart.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:stream_video/stream_video.dart'; -import 'package:stream_video_push_notification/stream_video_push_notification_platform_interface.dart'; - -import 'stream_video_push_params.dart'; +import '../stream_video_push_notification.dart'; +import '../stream_video_push_notification_platform_interface.dart'; part 'stream_video_push_provider.dart'; const _idToken = 1; -const _idCallKit = 2; +const _idRinging = 2; const _idCallEnded = 3; const _idCallAccepted = 4; const _idCallRejected = 6; @@ -23,55 +22,12 @@ const _idActiveCall = 8; /// Implementation of [PushNotificationManager] for Stream Video. class StreamVideoPushNotificationManager implements PushNotificationManager { - static const userDeviceTokenKey = 'io.getstream.userDeviceToken'; - static const userDeviceTokenVoIPKey = 'io.getstream.userDeviceTokenVoIP'; - - /// Factory for creating a new instance of [StreamVideoPushNotificationManager]. - /// /// Parameters: - /// * [callerCustomizationCallback] callback providing customized caller data used for call screen and CallKit call. (for iOS this will only work for foreground calls) - static create({ - required StreamVideoPushProvider iosPushProvider, - required StreamVideoPushProvider androidPushProvider, - @Deprecated( - "Caller customization is deprecated as it was not fully compatible with iOS " - "(foreground calls only). Use 'display_name' custom field in the call instead. " - "See details: https://getstream.io/video/docs/flutter/advanced/incoming-calls/customization/#display-name-customization", - ) - CallerCustomizationFunction? callerCustomizationCallback, - @Deprecated( - 'Background handler is no longer needed for terminated state ringing on iOS.', - ) - BackgroundVoipCallHandler? backgroundVoipCallHandler, - StreamVideoPushParams? pushParams, - bool registerApnDeviceToken = false, - }) { - return (CoordinatorClient client, StreamVideo streamVideo) { - final params = _defaultPushParams.merge(pushParams); - - StreamVideoPushNotificationPlatform.instance.init( - params.toJson(), - callerCustomizationCallback, - ); - - return StreamVideoPushNotificationManager._( - client: client, - streamVideo: streamVideo, - iosPushProvider: iosPushProvider, - androidPushProvider: androidPushProvider, - pushParams: params, - callerCustomizationCallback: callerCustomizationCallback, - registerApnDeviceToken: registerApnDeviceToken, - ); - }; - } - StreamVideoPushNotificationManager._({ required CoordinatorClient client, required StreamVideo streamVideo, required this.iosPushProvider, required this.androidPushProvider, - required this.pushParams, - this.callerCustomizationCallback, + required this.pushConfiguration, this.registerApnDeviceToken = false, }) : _client = client { if (!CurrentPlatform.isMobile) return; @@ -100,7 +56,7 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { }), ); - subscribeToEvents() { + void subscribeToEvents() { _subscriptions.add( _idCallEnded, client.events.on((event) { @@ -131,7 +87,8 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { () => '[subscribeToEvents] No participants left, ending call: ${event.callCid}', ); - endCallByCid(event.callCid.toString()); + + await endCallByCid(event.callCid.toString()); } }), ); @@ -149,7 +106,8 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { () => '[subscribeToEvents] Call rejected by the current user or call owner, ending call: ${event.callCid}', ); - endCallByCid(event.callCid.toString()); + + await endCallByCid(event.callCid.toString()); } }), ); @@ -175,31 +133,32 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { ); await endCallByCid(event.callCid.toString()); } - // If the call was accepted on this device, end the CallKit call silently + // If the call was accepted on this device, end the Ringing call silently // (useful if the call was accepted via the app instead of the CallKit UI) else { _logger.v( () => '[subscribeToEvents] Call accepted on the same device, ending CallKit silently: ${event.callCid}', ); - await FlutterCallkitIncoming.silenceEvents(); + await StreamVideoPushNotificationPlatform.instance.silenceEvents(); await endCallByCid(event.callCid.toString()); await Future.delayed(const Duration(milliseconds: 300)); - await FlutterCallkitIncoming.unsilenceEvents(); + await StreamVideoPushNotificationPlatform.instance + .unsilenceEvents(); } }), ); } //if there are active calls (for iOS) when connecting, subscribe to events as if the call was incoming - FlutterCallkitIncoming.activeCalls().then((value) { + StreamVideoPushNotificationPlatform.instance.activeCalls().then((value) { if (value is List && value.isNotEmpty) { subscribeToEvents(); } }); _subscriptions.add( - _idCallKit, + _idRinging, onCallEvent.listen((event) { if (event is ActionCallToggleMute) { { @@ -236,17 +195,40 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { }), ); } + static const userDeviceTokenKey = 'io.getstream.userDeviceToken'; + static const userDeviceTokenVoIPKey = 'io.getstream.userDeviceTokenVoIP'; + + /// Factory for creating a new instance of [StreamVideoPushNotificationManager]. + static StreamVideoPushNotificationManager Function( + CoordinatorClient client, + StreamVideo streamVideo, + ) + create({ + required StreamVideoPushProvider iosPushProvider, + required StreamVideoPushProvider androidPushProvider, + StreamVideoPushConfiguration? pushConfiguration, + bool registerApnDeviceToken = false, + }) { + return (CoordinatorClient client, StreamVideo streamVideo) { + final configuration = _defaultPushConfiguration.merge(pushConfiguration); + + StreamVideoPushNotificationPlatform.instance.init(configuration.toJson()); + + return StreamVideoPushNotificationManager._( + client: client, + streamVideo: streamVideo, + iosPushProvider: iosPushProvider, + androidPushProvider: androidPushProvider, + pushConfiguration: configuration, + registerApnDeviceToken: registerApnDeviceToken, + ); + }; + } final CoordinatorClient _client; final StreamVideoPushProvider iosPushProvider; final StreamVideoPushProvider androidPushProvider; - final StreamVideoPushParams pushParams; - @Deprecated( - "Caller customization is deprecated as it was not fully compatible with iOS " - "(foreground calls only). Use 'display_name' custom field in the call instead. " - "See details: https://getstream.io/video/docs/flutter/advanced/incoming-calls/customization/#display-name-customization", - ) - final CallerCustomizationFunction? callerCustomizationCallback; + final StreamVideoPushConfiguration pushConfiguration; final bool registerApnDeviceToken; late SharedPreferences _sharedPreferences; @@ -269,13 +251,13 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { return; } - void registerDevice(String token, bool isVoIP) async { + Future registerDevice(String token, bool isVoIP) async { final tokenKey = isVoIP ? userDeviceTokenVoIPKey : userDeviceTokenKey; final storedToken = _sharedPreferences.getString(tokenKey); if (storedToken == token) return; - _client + await _client .createDevice( id: token, voipToken: isVoIP, @@ -327,10 +309,10 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { } @override - Stream get onCallEvent { - return StreamCallKit().onEvent - .map((event) => event.toCallKitEvent()) - .doOnData((event) => _logger.v(() => '[onCallKitEvent] event: $event')); + Stream get onCallEvent { + return RingingEventBroadcaster().onEvent + .doOnData((event) => _logger.v(() => '[onCallEvent] event: $event')) + .share(); } @override @@ -339,26 +321,25 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { required String callCid, String? avatar, String? handle, - String? nameCaller, + String? callerName, bool hasVideo = true, }) { - // ignore: deprecated_member_use_from_same_package - final customData = callerCustomizationCallback?.call( - callCid: callCid, - callerName: nameCaller, - callerHandle: handle, + final paramsFromConfig = StreamVideoPushParams.fromPushConfiguration( + pushConfiguration, ); - final params = pushParams.copyWith( + final params = paramsFromConfig.copyWith( id: uuid, - avatar: customData?.avatar ?? avatar, - handle: customData?.handle ?? handle, - nameCaller: customData?.name ?? nameCaller, + handle: handle, + callerName: callerName, type: hasVideo ? 1 : 0, extra: {'callCid': callCid}, + android: paramsFromConfig.android?.copyWith(avatar: avatar), ); - return FlutterCallkitIncoming.showCallkitIncoming(params); + return StreamVideoPushNotificationPlatform.instance.showIncomingCall( + params, + ); } @override @@ -367,26 +348,24 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { required String callCid, String? avatar, String? handle, - String? nameCaller, + String? callerName, bool hasVideo = true, }) { - // ignore: deprecated_member_use_from_same_package - final customData = callerCustomizationCallback?.call( - callCid: callCid, - callerName: nameCaller, - callerHandle: handle, + final paramsFromConfig = StreamVideoPushParams.fromPushConfiguration( + pushConfiguration, ); - final params = pushParams.copyWith( + final params = paramsFromConfig.copyWith( id: uuid, - avatar: customData?.avatar ?? avatar, - handle: customData?.handle ?? handle, - nameCaller: customData?.name ?? nameCaller, + handle: handle, + callerName: callerName, type: hasVideo ? 1 : 0, extra: {'callCid': callCid}, + android: paramsFromConfig.android?.copyWith(avatar: avatar), ); - return FlutterCallkitIncoming.showMissCallNotification(params); + return StreamVideoPushNotificationPlatform.instance + .showMissCallNotification(params); } @override @@ -395,26 +374,31 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { required String callCid, String? avatar, String? handle, - String? nameCaller, + String? callerName, bool hasVideo = true, }) { - final params = pushParams.copyWith( + final paramsFromConfig = StreamVideoPushParams.fromPushConfiguration( + pushConfiguration, + ); + + final params = paramsFromConfig.copyWith( id: uuid, - avatar: avatar, handle: handle, - nameCaller: nameCaller, + callerName: callerName, type: hasVideo ? 1 : 0, extra: {'callCid': callCid}, + android: paramsFromConfig.android?.copyWith(avatar: avatar), ); - return FlutterCallkitIncoming.startCall(params); + return StreamVideoPushNotificationPlatform.instance.startCall(params); } @override Future> activeCalls() async { if (!CurrentPlatform.isMobile) return []; - final activeCalls = await FlutterCallkitIncoming.activeCalls(); + final activeCalls = await StreamVideoPushNotificationPlatform.instance + .activeCalls(); if (activeCalls is! List) return []; final calls = []; @@ -431,10 +415,12 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { } @override - Future endAllCalls() => FlutterCallkitIncoming.endAllCalls(); + Future endAllCalls() => + StreamVideoPushNotificationPlatform.instance.endAllCalls(); @override - Future endCall(String uuid) => FlutterCallkitIncoming.endCall(uuid); + Future endCall(String uuid) => + StreamVideoPushNotificationPlatform.instance.endCall(uuid); @override Future endCallByCid(String cid) async { @@ -443,9 +429,8 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { .where((call) => call.callCid == cid && call.uuid != null) .toList(); - // This is a workaround for the issue in flutter_callkit_incoming - // where second CallKit call overrides data in showCallkitIncoming native method - // and it's not possible to end the call by callCid + // If multiple native ringing calls are stacked with identical metadata, + // ending by callCid may not be sufficient; fall back to endAllCalls. if (activeCalls.length == calls.length) { await endAllCalls(); } else { @@ -469,18 +454,21 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { for (final call in calls) { // Silence events to avoid infinite loop - FlutterCallkitIncoming.silenceEvents(); - await FlutterCallkitIncoming.muteCall(call.uuid!, isMuted: isMuted); - FlutterCallkitIncoming.unsilenceEvents(); + await StreamVideoPushNotificationPlatform.instance.silenceEvents(); + await StreamVideoPushNotificationPlatform.instance.muteCall( + call.uuid!, + isMuted: isMuted, + ); + await StreamVideoPushNotificationPlatform.instance.unsilenceEvents(); } } @override Future getDevicePushTokenVoIP() async { if (CurrentPlatform.isIos) { - return await StreamTokenProvider.getVoIPToken(); + return StreamTokenProvider.getVoIPToken(); } else if (CurrentPlatform.isAndroid) { - return await StreamTokenProvider.getFirebaseToken(); + return StreamTokenProvider.getFirebaseToken(); } return null; @@ -488,17 +476,23 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { @override Future holdCall(String uuid, {bool isOnHold = true}) { - return FlutterCallkitIncoming.holdCall(uuid, isOnHold: isOnHold); + return StreamVideoPushNotificationPlatform.instance.holdCall( + uuid, + isOnHold: isOnHold, + ); } @override Future muteCall(String uuid, {bool isMuted = true}) { - return FlutterCallkitIncoming.muteCall(uuid, isMuted: isMuted); + return StreamVideoPushNotificationPlatform.instance.muteCall( + uuid, + isMuted: isMuted, + ); } @override - Future setCallConnected(uuid) { - return FlutterCallkitIncoming.setCallConnected(uuid); + Future setCallConnected(String uuid) { + return StreamVideoPushNotificationPlatform.instance.setCallConnected(uuid); } @override @@ -506,38 +500,34 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { _subscriptions.cancelAll(); } - static Future ensureFullScreenIntentPermission() { + static Future ensureFullScreenIntentPermission() { return StreamVideoPushNotificationPlatform.instance .ensureFullScreenIntentPermission(); } } -const _defaultPushParams = StreamVideoPushParams( - duration: 30000, - textAccept: 'Accept', - textDecline: 'Decline', - missedCallNotification: NotificationParams( - showNotification: true, - isShowCallback: true, - subtitle: 'Missed call', - callbackText: 'Call back', - ), - callingNotification: NotificationParams(showNotification: false), - android: AndroidParams( - isCustomNotification: true, - isShowLogo: false, +const _defaultPushConfiguration = StreamVideoPushConfiguration( + android: AndroidPushConfiguration( ringtonePath: 'system_ringtone_default', - backgroundColor: '#0955fa', - actionColor: '#4CAF50', - incomingCallNotificationChannelName: "Incoming Call", + incomingCallNotificationChannelName: 'Incoming Call', + missedCallNotification: MissedCallNotificationParams( + showNotification: true, + showCallbackButton: true, + subtitle: 'Missed call', + callbackText: 'Call back', + ), + incomingCallNotification: IncomingCallNotificationParams( + fullScreenShowLogo: false, + fullScreenBackgroundColor: '#0955fa', + ), ), - ios: IOSParams( + ios: IOSPushConfiguration( handleType: 'generic', supportsVideo: true, maximumCallGroups: 1, audioSessionMode: 'default', audioSessionActive: true, - audioSessionPreferredSampleRate: 44100.0, + audioSessionPreferredSampleRate: 44100, audioSessionPreferredIOBufferDuration: 0.005, supportsDTMF: true, supportsHolding: false, @@ -552,81 +542,42 @@ CallData _callDataFromJson(Map json) { return CallData( uuid: json['id'] as String?, callCid: extraData?['callCid'] as String?, - avatar: json['avatar'] as String?, handle: json['handle'] as String?, - nameCaller: json['nameCaller'] as String?, + callerName: json['callerName'] as String?, hasVideo: json['type'] == 1, extraData: extraData, ); } -extension on CallEvent { - CallData toCallData() => _callDataFromJson(body); - - CallKitEvent toCallKitEvent() { - return switch (event) { - Event.actionCallIncoming => ActionCallIncoming(data: toCallData()), - Event.actionCallStart => ActionCallStart(data: toCallData()), - Event.actionCallAccept => ActionCallAccept(data: toCallData()), - Event.actionCallDecline => ActionCallDecline(data: toCallData()), - Event.actionCallEnded => ActionCallEnded(data: toCallData()), - Event.actionCallTimeout => ActionCallTimeout(data: toCallData()), - Event.actionCallCallback => ActionCallCallback(data: toCallData()), - Event.actionCallConnected => ActionCallConnected(data: toCallData()), - Event.actionDidUpdateDevicePushTokenVoip => - ActionDidUpdateDevicePushTokenVoip( - token: body['deviceTokenVoIP'] as String, - ), - Event.actionCallToggleHold => ActionCallToggleHold( - uuid: body['id'] as String, - isOnHold: body['isOnHold'] as bool, - ), - Event.actionCallToggleMute => ActionCallToggleMute( - uuid: body['id'] as String, - isMuted: body['isMuted'] as bool, - ), - Event.actionCallToggleDmtf => ActionCallToggleDmtf( - uuid: body['id'] as String, - digits: body['digits'] as String, - ), - Event.actionCallToggleGroup => ActionCallToggleGroup( - uuid: body['id'] as String, - callUUIDToGroupWith: body['callUUIDToGroupWith'] as String, - ), - Event.actionCallToggleAudioSession => ActionCallToggleAudioSession( - isActivate: body['isActivate'] as bool, - ), - Event.actionCallCustom => ActionCallCustom(body), - }; - } -} - /// Wrapper class to support multiple subscriptions to the -/// [FlutterCallkitIncoming.onEvent] stream. -final class StreamCallKit { - factory StreamCallKit() => _singleton ??= StreamCallKit._(); +/// [StreamVideoPushNotificationPlatform.onEvent] stream. +final class RingingEventBroadcaster { + factory RingingEventBroadcaster() => + _singleton ??= RingingEventBroadcaster._(); - StreamCallKit._(); + RingingEventBroadcaster._(); - static StreamCallKit? _singleton; + static RingingEventBroadcaster? _singleton; - StreamController? _controller; + StreamController? _controller; - /// Returns a Stream of [CallEvent]. - Stream get onEvent { - _controller ??= StreamController.broadcast( + /// Returns a Stream of [RingingEvent]. + Stream get onEvent { + _controller ??= StreamController.broadcast( onListen: _startListenEvent, onCancel: _stopListenEvent, ); return _controller!.stream; } - StreamSubscription? _eventSubscription; + StreamSubscription? _eventSubscription; Future _startListenEvent() async { - _eventSubscription ??= FlutterCallkitIncoming.onEvent.listen((event) { - if (event != null) _controller?.add(event); - }); + _eventSubscription ??= StreamVideoPushNotificationPlatform.instance.onEvent + .distinct() + .listen((event) { + if (event != null) _controller?.add(event); + }); } Future _stopListenEvent() async { diff --git a/packages/stream_video_push_notification/lib/src/stream_video_push_params.dart b/packages/stream_video_push_notification/lib/src/stream_video_push_params.dart index 71103274c..bf4d5175a 100644 --- a/packages/stream_video_push_notification/lib/src/stream_video_push_params.dart +++ b/packages/stream_video_push_notification/lib/src/stream_video_push_params.dart @@ -1,78 +1,80 @@ -import 'package:flutter_callkit_incoming/entities/entities.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; +import '../stream_video_push_notification.dart'; +import 'stream_video_push_configuration.dart'; + part 'stream_video_push_params.g.dart'; @JsonSerializable(explicitToJson: true) -class StreamVideoPushParams extends CallKitParams { +class StreamVideoPushParams { const StreamVideoPushParams({ - super.id, - super.nameCaller, - super.appName, - super.avatar, - super.handle, - super.type, - super.duration, - super.textAccept, - super.textDecline, - super.missedCallNotification, - super.callingNotification, - super.extra, - super.headers, - super.android, - super.ios, + this.id, + this.callerName, + this.handle, + this.type, + this.duration, + this.extra, + this.headers, + this.android, + this.ios, }); + factory StreamVideoPushParams.fromPushConfiguration( + StreamVideoPushConfiguration configuration, + ) => StreamVideoPushParams( + headers: configuration.headers, + android: configuration.android != null + ? AndroidParams.fromPushConfiguration(configuration.android!) + : null, + ios: configuration.ios != null + ? IOSParams.fromPushConfiguration(configuration.ios!) + : null, + ); + + factory StreamVideoPushParams.fromJson(Map json) => + _$StreamVideoPushParamsFromJson(json); + const StreamVideoPushParams._internal({ - super.id, - super.nameCaller, - super.appName, - super.avatar, - super.handle, - super.type, - super.duration, - super.textAccept, - super.textDecline, - super.missedCallNotification, - super.callingNotification, - super.extra, - super.headers, - super.android, - super.ios, + this.id, + this.callerName, + this.handle, + this.type, + this.duration, + this.extra, + this.headers, + this.android, + this.ios, }); + final String? id; + final String? callerName; + final String? handle; + final int? type; + final int? duration; + final Map? extra; + final Map? headers; + final AndroidParams? android; + final IOSParams? ios; + @internal StreamVideoPushParams copyWith({ String? id, - String? nameCaller, - String? appName, - String? avatar, + String? callerName, String? handle, int? type, int? duration, - String? textAccept, - String? textDecline, - NotificationParams? missedCallNotification, - NotificationParams? callingNotification, - Map? extra, - Map? headers, + Map? extra, + Map? headers, AndroidParams? android, IOSParams? ios, }) { return StreamVideoPushParams._internal( id: id ?? this.id, - nameCaller: nameCaller ?? this.nameCaller, - appName: appName ?? this.appName, - avatar: avatar ?? this.avatar, + callerName: callerName ?? this.callerName, handle: handle ?? this.handle, type: type ?? this.type, duration: duration ?? this.duration, - textAccept: textAccept ?? this.textAccept, - textDecline: textDecline ?? this.textDecline, - missedCallNotification: - missedCallNotification ?? this.missedCallNotification, - callingNotification: callingNotification ?? this.callingNotification, extra: extra ?? this.extra, headers: headers ?? this.headers, android: android ?? this.android, @@ -86,38 +88,208 @@ class StreamVideoPushParams extends CallKitParams { return copyWith( id: other.id, - nameCaller: other.nameCaller, - appName: other.appName, - avatar: other.avatar, + callerName: other.callerName, handle: other.handle, type: other.type, duration: other.duration, - textAccept: other.textAccept, - textDecline: other.textDecline, - missedCallNotification: missedCallNotification?.merge( - other.missedCallNotification, - ), - callingNotification: callingNotification?.merge( - other.callingNotification, - ), extra: other.extra, headers: other.headers, - android: android?.merge(other.android), - ios: ios?.merge(other.ios), + android: android?.merge(other.android) ?? other.android, + ios: ios?.merge(other.ios) ?? other.ios, ); } - factory StreamVideoPushParams.fromJson(Map json) => - _$StreamVideoPushParamsFromJson(json); - - @override Map toJson() => _$StreamVideoPushParamsToJson(this); } -extension on IOSParams { +@JsonSerializable(explicitToJson: true) +class AndroidParams { + const AndroidParams({ + this.avatar, + this.defaultAvatar, + this.ringtonePath, + this.incomingCallNotificationChannelName, + this.missedCallNotificationChannelName, + this.showFullScreenOnLockScreen, + this.isImportant, + this.isBot, + this.missedCallNotification, + this.incomingCallNotification, + }); + + factory AndroidParams.fromPushConfiguration( + AndroidPushConfiguration configuration, + ) => AndroidParams( + defaultAvatar: configuration.defaultAvatar, + ringtonePath: configuration.ringtonePath, + incomingCallNotificationChannelName: + configuration.incomingCallNotificationChannelName, + missedCallNotificationChannelName: + configuration.missedCallNotificationChannelName, + showFullScreenOnLockScreen: configuration.showFullScreenOnLockScreen, + missedCallNotification: configuration.missedCallNotification, + incomingCallNotification: configuration.incomingCallNotification, + ); + + factory AndroidParams.fromJson(Map json) => + _$AndroidParamsFromJson(json); + + final MissedCallNotificationParams? missedCallNotification; + + final IncomingCallNotificationParams? incomingCallNotification; + + final String? avatar; + + /// Default avatar for call, example: http://... https://... or "assets/abc.png" + final String? defaultAvatar; + + /// File name ringtone, put file into /android/app/src/main/res/raw/ringtone_default.mp3 -> value: `ringtone_default` + final String? ringtonePath; + + /// Notification channel name of incoming call. + final String? incomingCallNotificationChannelName; + + /// Notification channel name of missed call. + final String? missedCallNotificationChannelName; + + /// Show full locked screen. + final bool? showFullScreenOnLockScreen; + + /// Caller is important to the user of this device with regards to how frequently they interact. + /// https://developer.android.com/reference/androidx/core/app/Person#isImportant() + final bool? isImportant; + + /// Used primarily to identify automated tooling. + /// https://developer.android.com/reference/androidx/core/app/Person#isBot() + final bool? isBot; + + AndroidParams copyWith({ + String? avatar, + String? defaultAvatar, + String? ringtonePath, + String? incomingCallNotificationChannelName, + String? missedCallNotificationChannelName, + bool? showFullScreenOnLockScreen, + bool? isImportant, + bool? isBot, + IncomingCallNotificationParams? incomingCallNotification, + MissedCallNotificationParams? missedCallNotification, + }) { + return AndroidParams( + avatar: avatar ?? this.avatar, + defaultAvatar: defaultAvatar ?? this.defaultAvatar, + ringtonePath: ringtonePath ?? this.ringtonePath, + incomingCallNotificationChannelName: + incomingCallNotificationChannelName ?? + this.incomingCallNotificationChannelName, + missedCallNotificationChannelName: + missedCallNotificationChannelName ?? + this.missedCallNotificationChannelName, + showFullScreenOnLockScreen: + showFullScreenOnLockScreen ?? this.showFullScreenOnLockScreen, + isImportant: isImportant ?? this.isImportant, + isBot: isBot ?? this.isBot, + incomingCallNotification: + incomingCallNotification ?? this.incomingCallNotification, + missedCallNotification: + missedCallNotification ?? this.missedCallNotification, + ); + } + + AndroidParams merge(AndroidParams? other) { + if (other == null) return this; + + return copyWith( + avatar: other.avatar, + defaultAvatar: other.defaultAvatar, + ringtonePath: other.ringtonePath, + incomingCallNotificationChannelName: + other.incomingCallNotificationChannelName, + missedCallNotificationChannelName: + other.missedCallNotificationChannelName, + showFullScreenOnLockScreen: other.showFullScreenOnLockScreen, + isImportant: other.isImportant, + isBot: other.isBot, + incomingCallNotification: other.incomingCallNotification, + missedCallNotification: other.missedCallNotification, + ); + } + + Map toJson() => _$AndroidParamsToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class IOSParams { + const IOSParams({ + this.iconName, + this.handleType, + this.useComplexHandle, + this.supportsVideo, + this.maximumCallGroups, + this.maximumCallsPerCallGroup, + this.audioSessionMode, + this.audioSessionActive, + this.audioSessionPreferredSampleRate, + this.audioSessionPreferredIOBufferDuration, + this.configureAudioSession, + this.supportsDTMF, + this.supportsHolding, + this.supportsGrouping, + this.supportsUngrouping, + this.ringtonePath, + }); + + factory IOSParams.fromPushConfiguration(IOSPushConfiguration configuration) => + IOSParams( + iconName: configuration.iconName, + handleType: configuration.handleType, + useComplexHandle: configuration.useComplexHandle, + supportsVideo: configuration.supportsVideo, + maximumCallGroups: configuration.maximumCallGroups, + maximumCallsPerCallGroup: configuration.maximumCallsPerCallGroup, + audioSessionMode: configuration.audioSessionMode, + audioSessionActive: configuration.audioSessionActive, + audioSessionPreferredSampleRate: + configuration.audioSessionPreferredSampleRate, + audioSessionPreferredIOBufferDuration: + configuration.audioSessionPreferredIOBufferDuration, + configureAudioSession: configuration.configureAudioSession, + supportsDTMF: configuration.supportsDTMF, + supportsHolding: configuration.supportsHolding, + supportsGrouping: configuration.supportsGrouping, + supportsUngrouping: configuration.supportsUngrouping, + ringtonePath: configuration.ringtonePath, + ); + + factory IOSParams.fromJson(Map json) => + _$IOSParamsFromJson(json); + + /// App's Icon. using for display inside Callkit(iOS) + final String? iconName; + + /// Type handle call `generic`, `number`, `email` + final String? handleType; + final bool? useComplexHandle; + final bool? supportsVideo; + final int? maximumCallGroups; + final int? maximumCallsPerCallGroup; + final String? audioSessionMode; + final bool? audioSessionActive; + final double? audioSessionPreferredSampleRate; + final double? audioSessionPreferredIOBufferDuration; + final bool? configureAudioSession; + final bool? supportsDTMF; + final bool? supportsHolding; + final bool? supportsGrouping; + final bool? supportsUngrouping; + + /// Add file to root project xcode /ios/Runner/Ringtone.caf and Copy Bundle Resources(Build Phases) -> value: "Ringtone.caf" + final String? ringtonePath; + IOSParams copyWith({ String? iconName, String? handleType, + bool? useComplexHandle, bool? supportsVideo, int? maximumCallGroups, int? maximumCallsPerCallGroup, @@ -135,6 +307,7 @@ extension on IOSParams { return IOSParams( iconName: iconName ?? this.iconName, handleType: handleType ?? this.handleType, + useComplexHandle: useComplexHandle ?? this.useComplexHandle, supportsVideo: supportsVideo ?? this.supportsVideo, maximumCallGroups: maximumCallGroups ?? this.maximumCallGroups, maximumCallsPerCallGroup: @@ -163,6 +336,7 @@ extension on IOSParams { return copyWith( iconName: other.iconName, handleType: other.handleType, + useComplexHandle: other.useComplexHandle, supportsVideo: other.supportsVideo, maximumCallGroups: other.maximumCallGroups, maximumCallsPerCallGroup: other.maximumCallsPerCallGroup, @@ -179,86 +353,55 @@ extension on IOSParams { ringtonePath: other.ringtonePath, ); } -} -extension on AndroidParams { - AndroidParams copyWith({ - bool? isCustomNotification, - bool? isCustomSmallExNotification, - bool? isShowLogo, - String? ringtonePath, - String? backgroundColor, - String? backgroundUrl, - String? actionColor, - String? incomingCallNotificationChannelName, - String? missedCallNotificationChannelName, - }) { - return AndroidParams( - isCustomNotification: isCustomNotification ?? this.isCustomNotification, - isCustomSmallExNotification: - isCustomSmallExNotification ?? this.isCustomSmallExNotification, - isShowLogo: isShowLogo ?? this.isShowLogo, - ringtonePath: ringtonePath ?? this.ringtonePath, - backgroundColor: backgroundColor ?? this.backgroundColor, - backgroundUrl: backgroundUrl ?? this.backgroundUrl, - actionColor: actionColor ?? this.actionColor, - incomingCallNotificationChannelName: - incomingCallNotificationChannelName ?? - this.incomingCallNotificationChannelName, - missedCallNotificationChannelName: - missedCallNotificationChannelName ?? - this.missedCallNotificationChannelName, - ); - } + Map toJson() => _$IOSParamsToJson(this); +} - AndroidParams merge(AndroidParams? other) { - if (other == null) return this; +@JsonSerializable(explicitToJson: true) +class MissedCallNotificationParams { + const MissedCallNotificationParams({ + this.id, + this.showNotification, + this.subtitle, + this.callbackText, + this.showCallbackButton, + this.count, + }); + factory MissedCallNotificationParams.fromJson(Map json) => + _$MissedCallNotificationParamsFromJson(json); - return copyWith( - isCustomNotification: other.isCustomNotification, - isCustomSmallExNotification: other.isCustomSmallExNotification, - isShowLogo: other.isShowLogo, - ringtonePath: other.ringtonePath, - backgroundColor: other.backgroundColor, - backgroundUrl: other.backgroundUrl, - actionColor: other.actionColor, - incomingCallNotificationChannelName: - other.incomingCallNotificationChannelName, - missedCallNotificationChannelName: - other.missedCallNotificationChannelName, - ); - } + final int? id; + final bool? showNotification; + final String? subtitle; + final String? callbackText; + final bool? showCallbackButton; + final int? count; + Map toJson() => _$MissedCallNotificationParamsToJson(this); } -extension on NotificationParams { - NotificationParams copyWith({ - int? id, - bool? showNotification, - String? subtitle, - String? callbackText, - bool? isShowCallback, - int? count, - }) { - return NotificationParams( - id: id ?? this.id, - showNotification: showNotification ?? this.showNotification, - subtitle: subtitle ?? this.subtitle, - callbackText: callbackText ?? this.callbackText, - isShowCallback: isShowCallback ?? this.isShowCallback, - count: count ?? this.count, - ); - } +@JsonSerializable(explicitToJson: true) +class IncomingCallNotificationParams { + const IncomingCallNotificationParams({ + this.fullScreenShowLogo, + this.fullScreenLogoUrl, + this.fullScreenBackgroundColor, + this.fullScreenBackgroundUrl, + this.fullScreenTextColor, + this.textAccept, + this.textDecline, + this.showCallHandle, + }); + factory IncomingCallNotificationParams.fromJson(Map json) => + _$IncomingCallNotificationParamsFromJson(json); - NotificationParams merge(NotificationParams? other) { - if (other == null) return this; + final bool? fullScreenShowLogo; + final String? fullScreenLogoUrl; + final String? fullScreenBackgroundColor; + final String? fullScreenBackgroundUrl; + final String? fullScreenTextColor; - return copyWith( - id: other.id, - showNotification: other.showNotification, - subtitle: other.subtitle, - callbackText: other.callbackText, - isShowCallback: other.isShowCallback, - count: other.count, - ); - } + final String? textAccept; + final String? textDecline; + final bool? showCallHandle; + Map toJson() => _$IncomingCallNotificationParamsToJson(this); } diff --git a/packages/stream_video_push_notification/lib/src/stream_video_push_params.g.dart b/packages/stream_video_push_notification/lib/src/stream_video_push_params.g.dart index 9e6b86d93..cf0ca5dbb 100644 --- a/packages/stream_video_push_notification/lib/src/stream_video_push_params.g.dart +++ b/packages/stream_video_push_notification/lib/src/stream_video_push_params.g.dart @@ -10,24 +10,10 @@ StreamVideoPushParams _$StreamVideoPushParamsFromJson( Map json, ) => StreamVideoPushParams( id: json['id'] as String?, - nameCaller: json['nameCaller'] as String?, - appName: json['appName'] as String?, - avatar: json['avatar'] as String?, + callerName: json['callerName'] as String?, handle: json['handle'] as String?, type: (json['type'] as num?)?.toInt(), duration: (json['duration'] as num?)?.toInt(), - textAccept: json['textAccept'] as String?, - textDecline: json['textDecline'] as String?, - missedCallNotification: json['missedCallNotification'] == null - ? null - : NotificationParams.fromJson( - json['missedCallNotification'] as Map, - ), - callingNotification: json['callingNotification'] == null - ? null - : NotificationParams.fromJson( - json['callingNotification'] as Map, - ), extra: json['extra'] as Map?, headers: json['headers'] as Map?, android: json['android'] == null @@ -42,18 +28,141 @@ Map _$StreamVideoPushParamsToJson( StreamVideoPushParams instance, ) => { 'id': instance.id, - 'nameCaller': instance.nameCaller, - 'appName': instance.appName, - 'avatar': instance.avatar, + 'callerName': instance.callerName, 'handle': instance.handle, 'type': instance.type, 'duration': instance.duration, - 'textAccept': instance.textAccept, - 'textDecline': instance.textDecline, - 'missedCallNotification': instance.missedCallNotification?.toJson(), - 'callingNotification': instance.callingNotification?.toJson(), 'extra': instance.extra, 'headers': instance.headers, 'android': instance.android?.toJson(), 'ios': instance.ios?.toJson(), }; + +AndroidParams _$AndroidParamsFromJson(Map json) => + AndroidParams( + avatar: json['avatar'] as String?, + defaultAvatar: json['defaultAvatar'] as String?, + ringtonePath: json['ringtonePath'] as String?, + incomingCallNotificationChannelName: + json['incomingCallNotificationChannelName'] as String?, + missedCallNotificationChannelName: + json['missedCallNotificationChannelName'] as String?, + showFullScreenOnLockScreen: json['showFullScreenOnLockScreen'] as bool?, + isImportant: json['isImportant'] as bool?, + isBot: json['isBot'] as bool?, + missedCallNotification: json['missedCallNotification'] == null + ? null + : MissedCallNotificationParams.fromJson( + json['missedCallNotification'] as Map, + ), + incomingCallNotification: json['incomingCallNotification'] == null + ? null + : IncomingCallNotificationParams.fromJson( + json['incomingCallNotification'] as Map, + ), + ); + +Map _$AndroidParamsToJson(AndroidParams instance) => + { + 'missedCallNotification': instance.missedCallNotification?.toJson(), + 'incomingCallNotification': instance.incomingCallNotification?.toJson(), + 'avatar': instance.avatar, + 'defaultAvatar': instance.defaultAvatar, + 'ringtonePath': instance.ringtonePath, + 'incomingCallNotificationChannelName': + instance.incomingCallNotificationChannelName, + 'missedCallNotificationChannelName': + instance.missedCallNotificationChannelName, + 'showFullScreenOnLockScreen': instance.showFullScreenOnLockScreen, + 'isImportant': instance.isImportant, + 'isBot': instance.isBot, + }; + +IOSParams _$IOSParamsFromJson(Map json) => IOSParams( + iconName: json['iconName'] as String?, + handleType: json['handleType'] as String?, + useComplexHandle: json['useComplexHandle'] as bool?, + supportsVideo: json['supportsVideo'] as bool?, + maximumCallGroups: (json['maximumCallGroups'] as num?)?.toInt(), + maximumCallsPerCallGroup: (json['maximumCallsPerCallGroup'] as num?)?.toInt(), + audioSessionMode: json['audioSessionMode'] as String?, + audioSessionActive: json['audioSessionActive'] as bool?, + audioSessionPreferredSampleRate: + (json['audioSessionPreferredSampleRate'] as num?)?.toDouble(), + audioSessionPreferredIOBufferDuration: + (json['audioSessionPreferredIOBufferDuration'] as num?)?.toDouble(), + configureAudioSession: json['configureAudioSession'] as bool?, + supportsDTMF: json['supportsDTMF'] as bool?, + supportsHolding: json['supportsHolding'] as bool?, + supportsGrouping: json['supportsGrouping'] as bool?, + supportsUngrouping: json['supportsUngrouping'] as bool?, + ringtonePath: json['ringtonePath'] as String?, +); + +Map _$IOSParamsToJson(IOSParams instance) => { + 'iconName': instance.iconName, + 'handleType': instance.handleType, + 'useComplexHandle': instance.useComplexHandle, + 'supportsVideo': instance.supportsVideo, + 'maximumCallGroups': instance.maximumCallGroups, + 'maximumCallsPerCallGroup': instance.maximumCallsPerCallGroup, + 'audioSessionMode': instance.audioSessionMode, + 'audioSessionActive': instance.audioSessionActive, + 'audioSessionPreferredSampleRate': instance.audioSessionPreferredSampleRate, + 'audioSessionPreferredIOBufferDuration': + instance.audioSessionPreferredIOBufferDuration, + 'configureAudioSession': instance.configureAudioSession, + 'supportsDTMF': instance.supportsDTMF, + 'supportsHolding': instance.supportsHolding, + 'supportsGrouping': instance.supportsGrouping, + 'supportsUngrouping': instance.supportsUngrouping, + 'ringtonePath': instance.ringtonePath, +}; + +MissedCallNotificationParams _$MissedCallNotificationParamsFromJson( + Map json, +) => MissedCallNotificationParams( + id: (json['id'] as num?)?.toInt(), + showNotification: json['showNotification'] as bool?, + subtitle: json['subtitle'] as String?, + callbackText: json['callbackText'] as String?, + showCallbackButton: json['showCallbackButton'] as bool?, + count: (json['count'] as num?)?.toInt(), +); + +Map _$MissedCallNotificationParamsToJson( + MissedCallNotificationParams instance, +) => { + 'id': instance.id, + 'showNotification': instance.showNotification, + 'subtitle': instance.subtitle, + 'callbackText': instance.callbackText, + 'showCallbackButton': instance.showCallbackButton, + 'count': instance.count, +}; + +IncomingCallNotificationParams _$IncomingCallNotificationParamsFromJson( + Map json, +) => IncomingCallNotificationParams( + fullScreenShowLogo: json['fullScreenShowLogo'] as bool?, + fullScreenLogoUrl: json['fullScreenLogoUrl'] as String?, + fullScreenBackgroundColor: json['fullScreenBackgroundColor'] as String?, + fullScreenBackgroundUrl: json['fullScreenBackgroundUrl'] as String?, + fullScreenTextColor: json['fullScreenTextColor'] as String?, + textAccept: json['textAccept'] as String?, + textDecline: json['textDecline'] as String?, + showCallHandle: json['showCallHandle'] as bool?, +); + +Map _$IncomingCallNotificationParamsToJson( + IncomingCallNotificationParams instance, +) => { + 'fullScreenShowLogo': instance.fullScreenShowLogo, + 'fullScreenLogoUrl': instance.fullScreenLogoUrl, + 'fullScreenBackgroundColor': instance.fullScreenBackgroundColor, + 'fullScreenBackgroundUrl': instance.fullScreenBackgroundUrl, + 'fullScreenTextColor': instance.fullScreenTextColor, + 'textAccept': instance.textAccept, + 'textDecline': instance.textDecline, + 'showCallHandle': instance.showCallHandle, +}; diff --git a/packages/stream_video_push_notification/lib/src/stream_video_push_provider.dart b/packages/stream_video_push_notification/lib/src/stream_video_push_provider.dart index 28822dcc0..e78046978 100644 --- a/packages/stream_video_push_notification/lib/src/stream_video_push_provider.dart +++ b/packages/stream_video_push_notification/lib/src/stream_video_push_provider.dart @@ -12,6 +12,14 @@ final class StreamVideoPushProvider { required TokenStreamProvider tokenStreamProvider, }) : _tokenStreamProvider = tokenStreamProvider; + /// Creates a new push provider for APN. + const StreamVideoPushProvider.apn({ + required this.name, + TokenStreamProvider tokenStreamProvider = _voIPTokenStreamProvider, + }) : isVoIP = true, + _tokenStreamProvider = tokenStreamProvider, + type = PushProvider.apn; + /// Creates a new push provider for Firebase. const StreamVideoPushProvider.firebase({ required this.name, @@ -27,14 +35,6 @@ final class StreamVideoPushProvider { yield* StreamTokenProvider.onFirebaseTokenRefresh; } - /// Creates a new push provider for APN. - const StreamVideoPushProvider.apn({ - required this.name, - TokenStreamProvider tokenStreamProvider = _voIPTokenStreamProvider, - }) : isVoIP = true, - _tokenStreamProvider = tokenStreamProvider, - type = PushProvider.apn; - static Stream _voIPTokenStreamProvider() async* { final initialToken = await StreamTokenProvider.getVoIPToken(); if (initialToken != null) yield initialToken; @@ -60,13 +60,16 @@ final class StreamVideoPushProvider { /// Provides push tokens for the device. final class StreamTokenProvider { + StreamTokenProvider._(); + /// Gets the current push token for the device. static Future getVoIPToken() async { if (!CurrentPlatform.isIos) return null; - final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP(); - if (token is! String || token.isEmpty) return null; + final token = await StreamVideoPushNotificationPlatform.instance + .getDevicePushTokenVoIP(); + if (token.isEmpty) return null; return token; } @@ -74,11 +77,9 @@ final class StreamTokenProvider { static Stream get onVoIPTokenRefresh { if (!CurrentPlatform.isIos) return const Stream.empty(); - return StreamCallKit().onEvent - .where((it) { - return it.event == Event.actionDidUpdateDevicePushTokenVoip; - }) - .map((event) => event.body['deviceTokenVoIP']); + return RingingEventBroadcaster().onEvent + .whereType() + .map((event) => event.token); } static Future getAPNToken() async { diff --git a/packages/stream_video_push_notification/lib/stream_video_push_notification.dart b/packages/stream_video_push_notification/lib/stream_video_push_notification.dart index 5acfd799c..a49406000 100644 --- a/packages/stream_video_push_notification/lib/stream_video_push_notification.dart +++ b/packages/stream_video_push_notification/lib/stream_video_push_notification.dart @@ -10,9 +10,7 @@ /// ringing experience. library stream_video_push_notification; -export 'package:flutter_callkit_incoming/entities/entities.dart' - show IOSParams, AndroidParams, NotificationParams; - +export 'src/stream_video_push_configuration.dart'; export 'src/stream_video_push_notification.dart' - hide StreamTokenProvider, StreamCallKit; + hide RingingEventBroadcaster, StreamTokenProvider; export 'src/stream_video_push_params.dart'; diff --git a/packages/stream_video_push_notification/lib/stream_video_push_notification_method_channel.dart b/packages/stream_video_push_notification/lib/stream_video_push_notification_method_channel.dart index deef723d9..9327a1020 100644 --- a/packages/stream_video_push_notification/lib/stream_video_push_notification_method_channel.dart +++ b/packages/stream_video_push_notification/lib/stream_video_push_notification_method_channel.dart @@ -1,8 +1,11 @@ +// ignore_for_file: avoid_dynamic_calls + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:stream_video/stream_video.dart'; - +import 'src/stream_video_call_event.dart'; +import 'src/stream_video_push_params.dart'; import 'stream_video_push_notification_platform_interface.dart'; /// An implementation of [StreamVideoPushNotificationPlatform] that uses method channels. @@ -11,46 +14,237 @@ class MethodChannelStreamVideoPushNotification /// The method channel used to interact with the native platform. @visibleForTesting final methodChannel = const MethodChannel('stream_video_push_notification'); + final eventChannel = const EventChannel( + 'stream_video_push_notification_events', + ); + + @override + Future init(Map pushConfiguration) async { + if (!CurrentPlatform.isIos) return; + await methodChannel.invokeMethod('initData', pushConfiguration); + } + + /// Only Android: show request permission for ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT + @override + Future ensureFullScreenIntentPermission() async { + if (!CurrentPlatform.isAndroid) return; + + await methodChannel.invokeMethod('ensureFullScreenIntentPermission'); + } + + @override + Stream get onEvent => + eventChannel.receiveBroadcastStream().map(_receiveRingingEvent); + + /// Show Incoming ringing call. + /// On iOS, using Callkit. On Android, using a custom UI. + @override + Future showIncomingCall(StreamVideoPushParams params) async { + await methodChannel.invokeMethod('showIncomingCall', params.toJson()); + } + + /// Show Miss Call Notification. + /// Only Android + @override + Future showMissCallNotification(StreamVideoPushParams params) async { + if (!CurrentPlatform.isAndroid) { + return; + } - CallerCustomizationFunction? callerCustomizationCallback; + await methodChannel.invokeMethod( + 'showMissCallNotification', + params.toJson(), + ); + } - MethodChannelStreamVideoPushNotification() { - methodChannel.setMethodCallHandler((call) async { - if (call.method == "customizeCaller") { - final name = call.arguments["created_by_display_name"]; - final handle = call.arguments["created_by_id"]; - final callId = call.arguments["call_cid"]; + /// Hide notification call for Android. + /// Only Android + @override + Future hideIncomingCall(StreamVideoPushParams params) async { + if (!CurrentPlatform.isAndroid) { + return; + } - final result = callerCustomizationCallback?.call( - callCid: callId, - callerName: name, - callerHandle: handle, - ); + await methodChannel.invokeMethod('hideIncomingCall', params.toJson()); + } - if (result != null) { - return result.toJson(); - } + /// Start an Outgoing call. + /// On iOS, using Callkit(create a history into the Phone app). + /// On Android, Nothing(only callback event listener). + @override + Future startCall(StreamVideoPushParams params) async { + await methodChannel.invokeMethod('startCall', params.toJson()); + } - return null; - } + /// Muting an Ongoing call. + /// On iOS, using Callkit(update the ongoing call ui). + /// On Android, Nothing(only callback event listener). + @override + Future muteCall(String id, {bool isMuted = true}) async { + await methodChannel.invokeMethod('muteCall', { + 'id': id, + 'isMuted': isMuted, }); } + /// Get Callkit Mic Status (muted/unmuted). + /// On iOS, using Callkit(update call ui). + /// On Android, Nothing(only callback event listener). @override - Future init( - Map pushParams, - CallerCustomizationFunction? callerCustomizationCallback, - ) async { - if (!CurrentPlatform.isIos) return; + Future isMuted(String id) async { + return (await methodChannel.invokeMethod('isMuted', {'id': id})) as bool? ?? + false; + } - this.callerCustomizationCallback = callerCustomizationCallback; - await methodChannel.invokeMethod('initData', pushParams); + /// Hold an Ongoing call. + /// On iOS, using Callkit(update the ongoing call ui). + /// On Android, Nothing(only callback event listener). + @override + Future holdCall(String id, {bool isOnHold = true}) async { + await methodChannel.invokeMethod('holdCall', { + 'id': id, + 'isOnHold': isOnHold, + }); } + /// End an Incoming/Outgoing call. + /// On iOS, using Callkit(update a history into the Phone app). + /// On Android, Nothing(only callback event listener). @override - Future ensureFullScreenIntentPermission() async { - if (!CurrentPlatform.isAndroid) return; + Future endCall(String id) async { + await methodChannel.invokeMethod('endCall', {'id': id}); + } - await methodChannel.invokeMethod('ensureFullScreenIntentPermission'); + /// Set call has been connected successfully. + /// On iOS, using Callkit(update a history into the Phone app). + /// On Android, Nothing(only callback event listener). + @override + Future setCallConnected(String id) async { + await methodChannel.invokeMethod('callConnected', {'id': id}); + } + + /// End all calls. + @override + Future endAllCalls() async { + await methodChannel.invokeMethod('endAllCalls'); + } + + /// Get active calls. + /// On iOS: return active calls from Callkit. + /// On Android: only return last call + @override + Future activeCalls() async { + return methodChannel.invokeMethod('activeCalls'); + } + + /// Get device push token VoIP. + /// On iOS: return deviceToken for VoIP. + /// On Android: return Empty + @override + Future getDevicePushTokenVoIP() async { + if (!CurrentPlatform.isIos) return ''; + final token = await methodChannel.invokeMethod('getDevicePushTokenVoIP'); + return token ?? ''; + } + + /// Silence Ringing events + @override + Future silenceEvents() async { + return methodChannel.invokeMethod('silenceEvents', true); + } + + /// Unsilence Ringing events + @override + Future unsilenceEvents() async { + return methodChannel.invokeMethod('silenceEvents', false); + } + + /// Request permission show notification for Android(13) + /// Only Android: show request permission post notification for Android 13+ + @override + Future requestNotificationPermission(dynamic data) async { + if (!CurrentPlatform.isAndroid) { + throw UnimplementedError( + 'requestNotificationPermission() is only implemented for Android.', + ); + } + + return methodChannel.invokeMethod( + 'requestNotificationPermission', + data, + ); + } + + /// Check can use full screen intent for Android(14)+ + /// Only Android: canUseFullScreenIntent permission for ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT + @override + Future canUseFullScreenIntent() async { + if (!CurrentPlatform.isAndroid) { + throw UnimplementedError( + 'canUseFullScreenIntent() is only implemented for Android.', + ); + } + + final allowed = await methodChannel.invokeMethod( + 'canUseFullScreenIntent', + ); + return allowed ?? false; + } + + CallData _callDataFromJson(Map json) { + final extraData = json['extra']?.cast(); + return CallData( + uuid: json['id'] as String?, + callCid: extraData?['callCid'] as String?, + handle: json['handle'] as String?, + callerName: json['callerName'] as String?, + hasVideo: json['type'] == 1, + extraData: extraData, + ); + } + + RingingEvent? _receiveRingingEvent(dynamic data) { + if (data is Map) { + final event = Event.values.firstWhere((e) => e.name == data['event']); + final body = Map.from(data['body']); + final callData = _callDataFromJson(body); + + return switch (event) { + Event.actionCallIncoming => ActionCallIncoming(data: callData), + Event.actionCallStart => ActionCallStart(data: callData), + Event.actionCallAccept => ActionCallAccept(data: callData), + Event.actionCallDecline => ActionCallDecline(data: callData), + Event.actionCallEnded => ActionCallEnded(data: callData), + Event.actionCallTimeout => ActionCallTimeout(data: callData), + Event.actionCallCallback => ActionCallCallback(data: callData), + Event.actionCallConnected => ActionCallConnected(data: callData), + Event.actionDidUpdateDevicePushTokenVoip => + ActionDidUpdateDevicePushTokenVoip( + token: body['deviceTokenVoIP'] as String, + ), + Event.actionCallToggleHold => ActionCallToggleHold( + uuid: body['id'] as String, + isOnHold: body['isOnHold'] as bool, + ), + Event.actionCallToggleMute => ActionCallToggleMute( + uuid: body['id'] as String, + isMuted: body['isMuted'] as bool, + ), + Event.actionCallToggleDtmf => ActionCallToggleDtmf( + uuid: body['id'] as String, + digits: body['digits'] as String, + ), + Event.actionCallToggleGroup => ActionCallToggleGroup( + uuid: body['id'] as String, + callUUIDToGroupWith: body['callUUIDToGroupWith'] as String, + ), + Event.actionCallToggleAudioSession => ActionCallToggleAudioSession( + isActive: body['isActive'] as bool, + ), + Event.actionCallCustom => ActionCallCustom(body), + }; + } + + return null; } } diff --git a/packages/stream_video_push_notification/lib/stream_video_push_notification_platform_interface.dart b/packages/stream_video_push_notification/lib/stream_video_push_notification_platform_interface.dart index 075736d7c..77d78019d 100644 --- a/packages/stream_video_push_notification/lib/stream_video_push_notification_platform_interface.dart +++ b/packages/stream_video_push_notification/lib/stream_video_push_notification_platform_interface.dart @@ -1,24 +1,8 @@ -import 'stream_video_push_notification_method_channel.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:stream_video/stream_video.dart'; +import 'src/stream_video_push_params.dart'; -class CallerCustomizationResponse { - final String? avatar; - final String? name; - final String? handle; - - CallerCustomizationResponse({this.name, this.handle, this.avatar}); - - Map toJson() { - return {"name": name, "handle": handle, "avatar": avatar}; - } -} - -typedef CallerCustomizationFunction = - CallerCustomizationResponse Function({ - required String callCid, - String? callerHandle, - String? callerName, - }); +import 'stream_video_push_notification_method_channel.dart'; typedef BackgroundVoipCallHandler = Future Function(); @@ -43,10 +27,7 @@ abstract class StreamVideoPushNotificationPlatform extends PlatformInterface { _instance = instance; } - Future init( - Map pushParams, - CallerCustomizationFunction? callerCustomizationCallback, - ) { + Future init(Map pushConfiguration) { throw UnimplementedError('init() has not been implemented.'); } @@ -55,4 +36,118 @@ abstract class StreamVideoPushNotificationPlatform extends PlatformInterface { 'ensureFullScreenIntentPermission() has not been implemented.', ); } + + /// Listen to event callback from Ringing flow. + Stream get onEvent { + throw UnimplementedError('onEvent has not been implemented.'); + } + + /// Show Incoming ringing call. + /// On iOS, using Callkit. On Android, using a custom UI. + Future showIncomingCall(StreamVideoPushParams params) { + throw UnimplementedError('showIncomingCall() has not been implemented.'); + } + + /// Show Miss Call Notification. + /// Only Android + Future showMissCallNotification(StreamVideoPushParams params) { + throw UnimplementedError( + 'showMissCallNotification() has not been implemented.', + ); + } + + /// Hide notification call for Android. + /// Only Android + Future hideIncomingCall(StreamVideoPushParams params) { + throw UnimplementedError('hideIncomingCall() has not been implemented.'); + } + + /// Start an Outgoing call. + /// On iOS, using Callkit(create a history into the Phone app). + /// On Android, Nothing(only callback event listener). + Future startCall(StreamVideoPushParams params) { + throw UnimplementedError('startCall() has not been implemented.'); + } + + /// Muting an Ongoing call. + /// On iOS, using Callkit(update the ongoing call ui). + /// On Android, Nothing(only callback event listener). + Future muteCall(String id, {bool isMuted = true}) { + throw UnimplementedError('muteCall() has not been implemented.'); + } + + /// Get Callkit Mic Status (muted/unmuted). + /// On iOS, using Callkit(update call ui). + /// On Android, Nothing(only callback event listener). + Future isMuted(String id) { + throw UnimplementedError('isMuted() has not been implemented.'); + } + + /// Hold an Ongoing call. + /// On iOS, using Callkit(update the ongoing call ui). + /// On Android, Nothing(only callback event listener). + Future holdCall(String id, {bool isOnHold = true}) { + throw UnimplementedError('holdCall() has not been implemented.'); + } + + /// End an Incoming/Outgoing call. + /// On iOS, using Callkit(update a history into the Phone app). + /// On Android, Nothing(only callback event listener). + Future endCall(String id) { + throw UnimplementedError('endCall() has not been implemented.'); + } + + /// Set call has been connected successfully. + /// On iOS, using Callkit(update a history into the Phone app). + /// On Android, Nothing(only callback event listener). + Future setCallConnected(String id) { + throw UnimplementedError('setCallConnected() has not been implemented.'); + } + + /// End all calls. + Future endAllCalls() { + throw UnimplementedError('endAllCalls() has not been implemented.'); + } + + /// Get active calls. + /// On iOS: return active calls from Callkit. + /// On Android: only return last call + Future activeCalls() { + throw UnimplementedError('activeCalls() has not been implemented.'); + } + + /// Get device push token VoIP. + /// On iOS: return deviceToken for VoIP. + /// On Android: return Empty + Future getDevicePushTokenVoIP() { + throw UnimplementedError( + 'getDevicePushTokenVoIP() has not been implemented.', + ); + } + + /// Silence CallKit events + Future silenceEvents() { + throw UnimplementedError('silenceEvents() has not been implemented.'); + } + + /// Unsilence CallKit events + Future unsilenceEvents() { + throw UnimplementedError('unsilenceEvents() has not been implemented.'); + } + + /// Request permission show notification for Android(13) + /// Only Android: show request permission post notification for Android 13+ + Future requestNotificationPermission(dynamic data) { + throw UnimplementedError( + 'requestNotificationPermission() has not been implemented.', + ); + } + + /// Check can use full screen intent for Android(14)+ + /// Only Android: canUseFullScreenIntent permission for ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT + Future canUseFullScreenIntent() { + throw UnimplementedError( + 'canUseFullScreenIntent() has not been implemented.', + ); + } } diff --git a/packages/stream_video_push_notification/pubspec.yaml b/packages/stream_video_push_notification/pubspec.yaml index d2c7d7b2d..1b49ca41d 100644 --- a/packages/stream_video_push_notification/pubspec.yaml +++ b/packages/stream_video_push_notification/pubspec.yaml @@ -12,39 +12,29 @@ environment: dependencies: collection: ^1.19.1 - firebase_core: ^4.1.0 - firebase_messaging: ^16.0.1 + firebase_core: ^4.1.1 + firebase_messaging: ^16.0.2 flutter: sdk: flutter - flutter_callkit_incoming: 2.5.7 json_annotation: ^4.9.0 meta: ^1.16.0 plugin_platform_interface: ^2.1.8 rxdart: ^0.28.0 shared_preferences: ^2.5.3 stream_video: ^0.11.0 + stream_video_flutter: ^0.11.0 stream_webrtc_flutter: ^1.0.12 uuid: ^4.5.1 dev_dependencies: - build_runner: ^2.4.4 - flutter_lints: ^2.0.2 + build_runner: ^2.9.0 + flutter_lints: ^2.0.3 flutter_test: sdk: flutter - json_serializable: ^6.6.1 - mocktail: ^1.0.0 + json_serializable: ^6.11.1 + mocktail: ^1.0.4 flutter: - # This section identifies this Flutter project as a plugin project. - # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) - # which should be registered in the plugin registry. This is required for - # using method channels. - # The Android 'package' specifies package in which the registered class is. - # This is required for using method channels on Android. - # The 'ffiPlugin' specifies that native code should be built and bundled. - # This is required for using `dart:ffi`. - # All these are used by the tooling to maintain consistency when - # adding or updating assets for this project. plugin: platforms: ios: