salesmanago_mobile_push 1.3.0 copy "salesmanago_mobile_push: ^1.3.0" to clipboard
salesmanago_mobile_push: ^1.3.0 copied to clipboard

SALESmanago Mobile Push plugin

Prerequisites #

  1. Minimum Supported OS Version: The SDK supports a minimum Android 5.0 Lollipop (API 21) and iOS 15.
  2. Environment: Minimum Flutter version: 3.3.0, Dart: 3.6.0

Installation #

Add the dependency in pubspec.yaml:

dependencies:
    salesmanago_mobile_push: 1.3.0

or via command line:

flutter pub add salesmanago_mobile_push

and run installation:

flutter pub get

What's new in 1.3.0 #

  • configurable HTTP(S) URL presentation for push redirections and In-App actions
  • improved iOS cold-start push-click tracking and routing
  • improved push deep-link navigation for Flutter applications, including Android Activity reuse, cold-start routing, and preserving the navigation stack
  • added getInitialDeepLink() to reliably recover an Android push deep link tapped while the app was killed, even when it races Flutter's own route delivery (see "Flutter navigation policy")

What's new in 1.2.1 #

  • removed redundant Android plugin service SalesmanagoMessagingService
  • clarified Android integration paths for default SDK flow and custom multi-provider flow

What's new in 1.2.0 #

  • support for newEmail in contact data updates
  • external events support (addExternalEvent, updateExternalEvent, deleteExternalEvent)
  • runtime In-App queue interval configuration with configure(inAppQueueInterval: ...)
  • init callback result support with initWithResult()
  • opt-in Android notification rendering improvements:
    • dedicated notification icon via manifest meta-data
    • BigTextStyle fallback when rich image is not available
    • SALESmanago/non-SALESmanago payload split for custom FirebaseMessagingService integrations
  • under-the-hood native SDK improvements inherited by Flutter plugin:
    • improved persisted request queue reliability
    • improved retry behavior and queue recovery after connectivity changes
    • improved push/deep link and In-App queue handling stability

Initialization #

All the methods available in the SDK are accessible via Salesmanago singleton class.

import 'package:salesmanago_mobile_push/sales_manago.dart';

In order to initialize SALESmanago SDK call init() method passing API key. This method must be called at the app startup.

Salesmanago.instance.init(<API key>);

If you want to observe initialization status, use initWithResult():

final initResult = await Salesmanago.instance.initWithResult(<API key>);
if (initResult.isSuccess) {
  // SDK initialized successfully
} else {
  // Initialization failed
  // initResult.errorMessage contains details when available
}

API key can be obtained from your SALESmanago dashboard.

Runtime reinitialization #

The SDK supports runtime reinitialization with a new API key. This allows switching between different SALESmanago API keys during the application's lifecycle without restarting the app.

The SDK switches to the new API key and reinitializes all necessary components and performs an initial synchronization.

All pending requests queued with the previous API key are sent before the API key swap. The SDK maintains contact IDs per API key. If an ID was previously associated with the API key, it will be reused.

To reinitialize the SDK with a new API key, call the reInit() method:

Salesmanago.instance.reInit(<new API key>);

Configuration #

configure returns a Future<void>. Await it when a subsequent SDK call depends on the configuration being applied.

There is a number of contact properties which can be set up with an SDK:

  • Contact data (name, email address, new email address, phone number, userId, company, address fields, birthday, standard details, double opt-in configuration, number and date details)
  • Marketing consents (email, mobile, monitoring)
  • Additional custom consents
  • List of tags

They can be all set with a single method:

import 'package:salesmanago_mobile_push/model/additional_consent.dart';
import 'package:salesmanago_mobile_push/model/contact_data.dart';
import 'package:salesmanago_mobile_push/model/contact_state.dart';
import 'package:salesmanago_mobile_push/model/double_opt_in.dart';
import 'package:salesmanago_mobile_push/model/marketing_consents.dart';
import 'package:salesmanago_mobile_push/model/opt_in_option.dart';

Salesmanago.instance.updateContactProperties(
  contactData: ContactData(
    name: 'John Doe',
    email: 'john.doe@email.com',
    newEmail: 'john.new@email.com',
    phone: '+48123456789',
    userId: 'user-123',
    company: 'Example Inc',
    state: ContactState.customer,
    birthday: 19900101,
    streetAddress: 'Main Street 10/2',
    city: 'Cracow',
    zipCode: '30-001',
    province: 'Lesser Poland',
    country: 'Poland',
    doubleOptIn: DoubleOptIn(
      emailId: 'f40bb45d-83b3-4e40-8211-b04e5faa0458',
      language: 'EN',
    ),
    standardDetails: {
      'first_detail': 'first_value',
      'second_detail': 'second_value',
    },
    numberDetails: {
      'purchasesCount': 12,
    },
    dateDetails: {
      'lastPurchase': 1696118400000,
    },
  ),
  marketingConsents: MarketingConsents(
    email: OptInOption.granted,
    mobile: OptInOption.granted,
    monitoring: OptInOption.denied,
  ),
  additionalConsents: [
    AdditionalConsent(
      name: 'custom consent',
      status: OptInOption.denied,
    ),
  ],
  tagsToAdd: ['tag_to_add'],
  tagsToRemove: ['tag_to_remove'],
);

OptInOption is an SDK enum class representing possible values for consents:

  • granted - Contact has given the consent
  • denied - Contact has rejected or withdrawn the consent
  • noAnswer - Contact has neither given nor rejected the consent. The status will not change. If there was no status, the consent will be set as rejected.

ContactState is an SDK enum class with the following values:

  • customer
  • prospect
  • partner
  • other
  • unknown

All parameters are optional so this method can be used to set just some of them. However there are also dedicated methods which can be used to set selected type of contact properties.

Contact data:

Salesmanago.instance.updateContactData(
  name: 'John Doe',
  email: 'john.doe@email.com',
  newEmail: 'john.new@email.com',
  phone: '+48123456789',
  userId: 'user-123',
  company: 'Example Inc',
  state: ContactState.customer,
  birthday: 19900101,
  streetAddress: 'Main Street 10/2',
  city: 'Cracow',
  zipCode: '30-001',
  province: 'Lesser Poland',
  country: 'Poland',
  doubleOptIn: DoubleOptIn(
    emailId: 'f40bb45d-83b3-4e40-8211-b04e5faa0458',
    language: 'EN',
  ),
  standardDetails: {
    'first_detail': 'first_value',
    'second_detail': 'second_value',
  },
  numberDetails: {
    'purchasesCount': 12,
  },
  dateDetails: {
    'lastPurchase': 1696118400000,
  }, 
);

Marketing consents:

Salesmanago.instance.updateContactMarketingConsents(
  email: OptInOption.denied,
  mobile: OptInOption.noAnswer,
  monitoring: OptInOption.granted,
);

Additional custom consents:

Salesmanago.instance.updateContactAdditionalConsents([
  AdditionalConsent(
    name: 'some custom consent',
    status: OptInOption.granted,
  ),
]);

Tags:

Salesmanago.instance.addTags(['only_tag_to_add']);
Salesmanago.instance.removeTags(['only_tag_to_remove']);

Set as null:

Salesmanago.instance.setAsNull(['phone']);

Events #

SALESmanago SDK enables to track user activity by sending pre-defined events:

import 'package:salesmanago_mobile_push/model/event_type.dart';

Salesmanago.instance.addEvent(EventType.login);

EventType is an SDK enum class representing possible event types. Currently following types are available:

  • login - user logs into the application

External events #

Track external events with transaction details.
External events can be recorded only for contacts with an e-mail.

While adding a new external event, the SDK generates an eventId (UUID) and returns it for further managing the event via other methods.

import 'package:salesmanago_mobile_push/model/external_event_type.dart';

final eventId = await Salesmanago.instance.addExternalEvent(
  eventTime: '1772700397',
  eventType: ExternalEventType.purchase,
  products: ['product1', 'product2', 'product3'],
  value: 99.99,
  location: 'shop123',
  externalId: 'order-456',
  detail1: 'Payment method: Credit Card',
  detail2: 'Shipping: Express',
  detail3: 'Discount: 10%',
  detail4: 'Customer segment: Premium',
  detail5: 'Campaign: Summer Sale',
  description: 'Example purchase transaction',
);

You can update or delete the event via updateExternalEvent and deleteExternalEvent methods. To specify the event for update, use eventId returned from the addExternalEvent method.

Salesmanago.instance.updateExternalEvent(
  eventId: 'fe964947-23b6-4950-a8a0-1848c20baf78',
  eventTime: '1772700397',
  eventType: ExternalEventType.cancellation,
  products: ['product1', 'product2', 'product3'],
  value: 1.0,
  detail1: 'Updated detail',
  description: 'Updated description',
);

Method deleteExternalEvent uses eventId or externalId to identify the event. At least one of these parameters has to be specified. If you specify both, the eventId takes priority.

Salesmanago.instance.deleteExternalEvent(
  eventId: 'fe964947-23b6-4950-a8a0-1848c20baf78',
  externalId: 'fe964947-23b6-4950-a8a0-1848c20baf78',
);

Push notifications #

Push notifications are sent via Firebase Cloud Messaging on Android and via APNs on iOS.

Your app has to ask a user to permit showing notifications. Without it, the SDK will not be allowed to show the notifications. After the user grants the permission, request the SDK to update its status via onPushNotificationSystemPermissionsChanged() method.

Additionally, there is a separate method to set the contact's opt-in status for receiving in-app marketing push notifications:

Salesmanago.instance.updateMobilePushOptIn(OptInOption.granted);

iOS #

Additional configuration for iOS is only required for deep links handling.

The plugin processes a push click that launches an inactive app after the SDK initialization succeeds. No additional setup is needed for click tracking when opening an HTTP(S) URL in the external browser.

The deep link scheme has to be declared in your Info.plist file. For instance, if you create a notification with the deep link salesmanago://salesmanago.com/main in the SM dashboard, your Info.plist file should contain:

<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
	<dict>
		<key>CFBundleTypeRole</key>
		<string>Editor</string>
		<key>CFBundleURLName</key>
		<string>YOUR APP BUNDLE IDENTIFIER</string>
		<key>CFBundleURLSchemes</key>
		<array>
			<string>salesmanago</string>
		</array>
	</dict>
</array>

You can also specify it in Xcode:

In your application target, open Info tab. In URL Types section click + and enter required data:

  • Identifier: your app bundle identifier
  • URL scheme: your unique scheme, in this example salesmanago

Now your Flutter application will receive the deeplink main in the navigation configuration:

MaterialApp(
  onGenerateRoute: (settings) {
    settings.name // this variable should contain 'main'
    );
  },
);

Note:
The iOS part of the Flutter framework treats deep links like URLs - the navigation works on paths and begins after scheme and host.
For instance, the deep link salesmanago://salesmanago.com/main will be truncated to just /main in the Flutter navigation.
When creating your deep links, be sure to include the host before path to navigate.

On Android, the app receives the full URL in the native intent. Flutter routing can receive either the full URL or a normalized path, depending on the Flutter embedding and configuration. Push-click tracking can also append query parameters, such as ?utm_medium=mobile_push. Parse the value as a Uri and match uri.path, rather than comparing the complete route string.

Flutter navigation policy #

The SDK opens the deep link intent, while Flutter owns the navigation stack. Android launchMode settings only control whether Android reuses MainActivity; they do not prevent Flutter from pushing another route for a deep link.

To guarantee that Home remains below a deep link after a cold start, force initialRoute: '/' and dispatch the platform route yourself after the first frame. This avoids relying on the platform-specific representation of Navigator.defaultRouteName:

final navigatorKey = GlobalKey<NavigatorState>();

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final routeName = WidgetsBinding.instance.platformDispatcher.defaultRouteName;
    if (isSupportedDeepLink(routeName)) {
      navigatorKey.currentState?.pushNamed(routeName);
    }
  });
}

// In MaterialApp:
navigatorKey: navigatorKey,
initialRoute: '/',

Android cold-start race: when a push notification is tapped while the app is killed, the native click handler launches the app and then delivers the deep link as a separate intent right after. Both resolve to the same MainActivity, so the second intent can arrive before Flutter's engine has attached its own route-delivery channel, and defaultRouteName above may never see it. Call Salesmanago.instance.getInitialDeepLink() once after your first frame as a fallback - it reads the intent independently of that channel, so it still finds the link when defaultRouteName misses it:

WidgetsBinding.instance.addPostFrameCallback((_) async {
  final routeName = WidgetsBinding.instance.platformDispatcher.defaultRouteName;
  if (isSupportedDeepLink(routeName)) {
    navigatorKey.currentState?.pushNamed(routeName);
    return;
  }

  final pendingLink = await Salesmanago.instance.getInitialDeepLink();
  if (pendingLink != null && isSupportedDeepLink(pendingLink)) {
    navigatorKey.currentState?.pushNamed(pendingLink);
  }
});

getInitialDeepLink() consumes the link it returns - a second call returns null until a new one arrives. This fallback is Android-specific; on iOS defaultRouteName alone is sufficient.

Decide how your app should handle a deep link to its current screen. For example, an onGenerateRoute implementation can resolve a supported URL to an internal route while ignoring tracking parameters:

String? resolveDeepLinkTarget(String? routeName) {
  final uri = Uri.tryParse(routeName ?? '');
  switch (uri?.path) {
    case '/account':
      return '/account';
    case '/orders':
      return '/orders';
    default:
      return null;
  }
}

The sample replaces only its technical resolver route with the deep link target. It preserves existing routes in the stack, matching the native Android behavior. Applications that want to deduplicate an existing target route must implement that policy themselves:

class DeepLinkNavigationArgs {
  const DeepLinkNavigationArgs({
    this.isResolved = true,
    this.queryParameters = const {},
  });

  final bool isResolved;
  final Map<String, String> queryParameters;
}

class DeepLinkResolverPage extends StatefulWidget {
  const DeepLinkResolverPage({
    required this.targetRoute,
    required this.queryParameters,
    super.key,
  });

  final String targetRoute;
  final Map<String, String> queryParameters;

  @override
  State<DeepLinkResolverPage> createState() => _DeepLinkResolverPageState();
}

class _DeepLinkResolverPageState extends State<DeepLinkResolverPage> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        Navigator.of(context).pushReplacementNamed(
          widget.targetRoute,
          arguments: DeepLinkNavigationArgs(
            queryParameters: widget.queryParameters,
          ),
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

Create the resolver with uri.queryParameters from the parsed URL. The target page can then read them from ModalRoute.of(context)!.settings.arguments as DeepLinkNavigationArgs, for example args.queryParameters['id'] or UTM parameters.

Use this resolver only for URLs your app supports, and map the URL to an internal route before creating it. When deciding whether to create the resolver, do not classify a route with resolved arguments as another deep link:

final args = settings.arguments;
if (args is DeepLinkNavigationArgs && args.isResolved) {
  return false;
}

The same route name can be used by platform deeplinks and local Navigator.pushNamed calls. For a local navigation to a route that is also a deep link target, pass const DeepLinkNavigationArgs() to mark it as already resolved and prevent an unintended stack reset:

Navigator.of(context).pushNamed(
  '/orders',
  arguments: const DeepLinkNavigationArgs(),
);

With this policy, a deep link to the currently displayed screen adds another instance of that screen. Back returns to the previous screen instance. Your application can instead choose a custom policy, such as ignoring a deep link to the current route or removing routes above an existing target.

iOS: Push routing with multiple providers #

When your app uses multiple push providers, route only SALESmanago payloads to the SDK. No separate mode is required on iOS.

If your app extends FlutterAppDelegate, two valid split patterns are common:

  • non-SALESmanago -> your own flow or super, SALESmanago -> Salesmanago.didReceiveRemoteNotification(...)
  • SALESmanago -> super, non-SALESmanago -> your own flow (sample app pattern)

In both patterns, keep the same payload split with Salesmanago.isSdkNotification(...) and call completionHandler exactly once per notification.

If you want to receive non-SALESmanago UNUserNotificationCenter callbacks (willPresent / didReceive), register a downstream notification delegate in your AppDelegate:

Salesmanago.setNotificationDelegate(self)

If your AppDelegate extends FlutterAppDelegate, use this pattern (forward non-SALESmanago payloads to super):

public override func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
    guard Salesmanago.isSdkNotification(userInfo: userInfo) else {
        // Handle non-SALESmanago payload in your own push flow.
        super.application(
            application,
            didReceiveRemoteNotification: userInfo,
            fetchCompletionHandler: completionHandler
        )
        return
    }

    Salesmanago.didReceiveRemoteNotification(userInfo: userInfo)
    completionHandler(.noData)
}

If you use your own non-SALESmanago push flow (without forwarding to super), keep the same split but ensure the non-SALESmanago path calls completionHandler exactly once in your own code:

public override func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
    guard Salesmanago.isSdkNotification(userInfo: userInfo) else {
        // Handle non-SALESmanago payload and call completionHandler once in this flow.
        handleNonSalesmanagoPayload(userInfo, completionHandler: completionHandler)
        return
    }

    Salesmanago.didReceiveRemoteNotification(userInfo: userInfo)
    completionHandler(.noData)
}

Do not call completionHandler on both paths for the same notification.

Android #

All the Firebase management work is done inside the SDK. The only thing to do is to connect the app with your Firebase project via JSON file generated in your Firebase project dashboard.

Paste your google-services.json file into the android app-level root directory (/android/app). Next, add the Google services Gradle plugin in your android project-level build.gradle file (/android/build.gradle):

plugins {
    // dependency for the Google services Gradle plugin
    id("com.google.gms.google-services") version "<version>" apply false
}

and in the android app-level build.gradle file ((/android/app/build.gradle):

plugins {
    id("com.android.application")

    // Google services Gradle plugin
    id("com.google.gms.google-services")

    ...
}

If you want to use deep links in your push notifications, you have to declare it in your app. The AndroidManifest.xml file declares your main activity (usually MainActivity) in an <activity> tag. A deep link has the form <scheme>://<host>/<path>. Declare matching values in a separate intent-filter.

host is the host from the deep link URL. It is not required to be the customer's web domain. For example, the deep link salesmanago://salesmanago.com/main requires:

<activity
    ...
    ...

    <intent-filter> 
        ...
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="salesmanago"
            android:host="salesmanago.com"
            android:pathPrefix="/main" />
    </intent-filter>
</activity>

If your app accepts every host and path for a scheme and resolves them in Flutter, a scheme-only filter is also valid:

<data android:scheme="salesmanago" />

Now Flutter receives the deep link in the navigation configuration:

MaterialApp(
  onGenerateRoute: (settings) {
    // settings.name contains the platform route for the configured deep link
    return MaterialPageRoute(
      builder: (_) => const Placeholder(),
    );
  },
);

Android: Reusing Flutter Activity after push clicks #

When a user clicks a push notification, the SDK reports the click and routes the user to the configured destination:

  • opens the app when no deep link is configured,
  • opens an app deep link when the link is handled by the app,
  • opens an external link in a browser or another external handler,
  • falls back to opening the app when the deep link cannot be handled.

Flutter apps usually host navigation in a single Android Activity. To prevent Android from creating another MainActivity instance when the app is already running, configure your Flutter Activity as reusable in android/app/src/main/AndroidManifest.xml.

Add android:launchMode="singleTask" to your Flutter MainActivity. The example below uses the same salesmanago://salesmanago.com/main URL; replace scheme, host, and pathPrefix with the values configured in your push deep link:

<activity
    android:name=".MainActivity"
    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
    android:exported="true"
    android:hardwareAccelerated="true"
    android:launchMode="singleTask"
    android:theme="@style/LaunchTheme"
    android:windowSoftInputMode="adjustResize">

    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="salesmanago"
            android:host="salesmanago.com"
            android:pathPrefix="/main" />
    </intent-filter>
</activity>

If your app needs to inspect the intent delivered to an already running Flutter Activity, override onNewIntent() in MainActivity.kt:

import android.content.Intent
import io.flutter.embedding.android.FlutterActivity

class MainActivity : FlutterActivity() {

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)

        // Optional: handle the new intent in your app if needed.
    }
}

With this setup:

  • cold start push click creates MainActivity normally,
  • background or foreground push click reuses the existing MainActivity,
  • onNewIntent() is called for an already running Activity,
  • SDK click tracking is still sent,
  • invalid or unhandled deep links fall back to opening the app.

singleTask prevents duplicate Android Activity instances only. Apply your Flutter navigation policy in onGenerateRoute, a router delegate, or your deep link handler to avoid duplicate Flutter routes.

Android: Default SDK flow #

In the default setup, you do not need any AndroidManifest changes for push handling. The native SDK auto-registers its default MessagingService.

Android: Optional custom service #

Use this only when you need custom multi-provider split routing.

To use a dedicated notification icon for Android notifications, add this meta-data entry inside your app <application> tag:

<meta-data
    android:name="com.salesmanago.mobilepush.notification_small_icon"
    android:resource="@drawable/ic_stat_salesmanago" />

If the meta-data is not provided, the SDK falls back to the application icon.

For best visibility on Android status bar, use a monochrome notification icon (white glyph on transparent background).

Rendering style rules:

  • if push payload contains a valid imageUrl, notification is shown using BigPictureStyle
  • otherwise, notification is shown using BigTextStyle with the push content

Register your own FirebaseMessagingService for multi-provider apps. Route SALESmanago payloads using Salesmanago.isSdkNotification(data) and forward only these payloads to SDK handling.

To enable this option, replace the SDK service in your app AndroidManifest.xml:

<manifest xmlns:tools="http://schemas.android.com/tools" ...>
  <application>
    <service
        android:name="com.salesmanago.library.common.messaging.MessagingService"
        tools:node="remove" />

    <service
        android:name="com.example.app.CustomAppMessagingService"
        android:exported="false"
        tools:node="replace">
        <intent-filter>
            <action android:name="com.google.firebase.MESSAGING_EVENT" />
        </intent-filter>
    </service>
  </application>
</manifest>

Example split in your custom service:

class CustomAppMessagingService : FirebaseMessagingService() {
  private val smService = MessagingService(this)

  override fun onMessageReceived(message: RemoteMessage) {
    if (Salesmanago.isSdkNotification(message.data)) {
      smService.onMessageReceived(message)
    } else {
      // Handle non-SALESmanago provider payload.
    }
  }

  override fun onNewToken(token: String) {
    smService.onNewToken(token)
  }
}

In-App #

For your app to display in-apps properly, your Android main activity should not block the affinity. It is usually done in the AndroidManifest.xml file under the <activity> tag via the attribute android:taskAffinity. Ensure your activity does not have an attribute android:taskAffinity="".

Queue Interval #

The SDK enforces a minimum delay between consecutive in-app message displays (default: 60 seconds). You can configure this interval at runtime using the configure() method. The value is specified in seconds.

await Salesmanago.instance.configure(inAppQueueInterval: 5);

URL presentation #

By default, HTTP(S) URLs from push redirections and in-app actions open in the device's external browser. Configure the SDK to use the native in-app browser instead:

import 'package:salesmanago_mobile_push/model/url_presentation.dart';

await Salesmanago.instance.configure(
  urlPresentation: UrlPresentation.inAppBrowser,
);

This setting affects only HTTP(S) URLs. Deep links and other URL schemes continue to use the system URL handler.