Tutorial: Send push notifications to Flutter apps using Azure Notification Hubs via a backend service

Download Sample Download the sample

In this tutorial, you use Azure Notification Hubs to push notifications to a Flutter application targeting Android and iOS.

An ASP.NET Core Web API backend is used to handle device registration for the client using the latest and best Installation approach. The service will also send push notifications in a cross-platform manner.

These operations are handled using the Notification Hubs SDK for backend operations. Further detail on the overall approach is provided in the Registering from your app backend documentation.

This tutorial takes you through the following steps:

Prerequisites

To follow along, you require:

For Android, you must have:

  • A developer unlocked physical device or an emulator (running API 26 and above with Google Play Services installed).

For iOS, you must have:

Note

The iOS Simulator does not support remote notifications and so a physical device is required when exploring this sample on iOS. However, you do not need to run the app on both Android and iOS in order to complete this tutorial.

You can follow the steps in this first-principles example with no prior experience. However, you'll benefit from having familiarity with the following aspects.

The steps provided are specific to macOS. It's possible to follow along on Windows by skipping the iOS aspects.

Set up Push Notification Services and Azure Notification Hub

In this section, you set up Firebase Cloud Messaging (FCM) and Apple Push Notification Services (APNS). You then create and configure a notification hub to work with those services.

Create a Firebase project and enable Firebase Cloud Messaging for Android

  1. Sign in to the Firebase console. Create a new Firebase project entering PushDemo as the Project name.

    Note

    A unique name will be generated for you. By default this is comprised of a lowercase variant of the name you provided plus a generated number separated by a dash. You can change this if you want provided it is still globally unique.

  2. After you create your project, select Add Firebase to your Android app.

    Add Firebase to your Android app

  3. On the Add Firebase to your Android app page, take the following steps.

    1. For the Android package name, enter a name for your package. For example: com.<organization_identifier>.<package_name>.

      Specify the package name

    2. Select Register app.

    3. Select Download google-services.json. Then save the file into a local folder for use later on and select Next.

      Download google-services.json

    4. Select Next.

    5. Select Continue to console

      Note

      If the Continue to console button is not enabled, due to the verify installation check, then choose Skip this step.

  4. In the Firebase console, select the cog for your project. Then select Project Settings.

    Select Project Settings

    Note

    If you haven't downloaded the google-services.json file, you can do download it on this page.

  5. Switch to the Cloud Messaging tab at the top. Copy and save the Server key for later use. You use this value to configure your notification hub.

    Copy server key

Register your iOS app for push notifications

To send push notifications to an iOS app, register your application with Apple, and also register for push notifications.

  1. If you haven't already registered your app, browse to the iOS Provisioning Portal at the Apple Developer Center. Sign in to the portal with your Apple ID, navigate to Certificates, Identifiers & Profiles, then select Identifiers. Click + to register a new app.

    iOS Provisioning Portal App IDs page

  2. On the Register a New Identifier screen, select the App IDs radio button. Then select Continue.

    iOS Provisioning Portal register new ID page

  3. Update the following three values for your new app, and then select Continue:

    • Description: Type a descriptive name for your app.

    • Bundle ID: Enter a Bundle ID of the form com.<organization_identifier>.<product_name> as mentioned in the App Distribution Guide. In the following screenshot, the mobcat value is used as an organization identifier and the PushDemo value is used as the product name.

      iOS Provisioning Portal register app ID page

    • Push Notifications: Check the Push Notifications option in the Capabilities section.

      Form to register a new App ID

      This action generates your App ID and requests that you confirm the information. Select Continue, then select Register to confirm the new App ID.

      Confirm new App ID

      After you select Register, you see the new App ID as a line item in the Certificates, Identifiers & Profiles page.

  4. In the Certificates, Identifiers & Profiles page, under Identifiers, locate the App ID line item that you created. Then, select its row to display the Edit your App ID Configuration screen.

Creating a certificate for Notification Hubs

A certificate is required to enable the notification hub to work with Apple Push Notification Services (APNS) and can be provided in one of two ways:

  1. Creating a p12 push certificate that can be uploaded directly to Notification Hub (the original approach)

  2. Creating a p8 certificate that can be used for token-based authentication (the newer and recommended approach)

The newer approach has a number of benefits as documented in Token-based (HTTP/2) authentication for APNS. Fewer steps are required but is also mandated for specific scenarios. However, steps have been provided for both approaches since either will work for the purposes of this tutorial.

OPTION 1: Creating a p12 push certificate that can be uploaded directly to Notification Hub
  1. On your Mac, run the Keychain Access tool. It can be opened from the Utilities folder or the Other folder on the Launchpad.

  2. Select Keychain Access, expand Certificate Assistant, and then select Request a Certificate from a Certificate Authority.

    Use Keychain Access to request a new certificate

    Note

    By default, Keychain Access selects the first item in the list. This can be a problem if you're in the Certificates category and Apple Worldwide Developer Relations Certification Authority is not the first item in the list. Make sure you have a non-key item, or the Apple Worldwide Developer Relations Certification Authority key is selected, before generating the CSR (Certificate Signing Request).

  3. Select your User Email Address, enter your Common Name value, make sure that you specify Saved to disk, and then select Continue. Leave CA Email Address blank as it isn't required.

    Expected certificate information

  4. Enter a name for the Certificate Signing Request (CSR) file in Save As, select the location in Where, and then select Save.

    Choose a file name for the certificate

    This action saves the CSR file in the selected location. The default location is Desktop. Remember the location chosen for the file.

  5. Back on the Certificates, Identifiers & Profiles page in the iOS Provisioning Portal, scroll down to the checked Push Notifications option, and then select Configure to create the certificate.

    Edit App ID page

  6. The Apple Push Notification service TLS/SSL Certificates window appears. Select the Create Certificate button under the Development TLS/SSL Certificate section.

    Create certificate for App ID button

    The Create a new Certificate screen is displayed.

    Note

    This tutorial uses a development certificate. The same process is used when registering a production certificate. Just make sure that you use the same certificate type when sending notifications.

  7. Select Choose File, browse to the location where you saved the CSR file, and then double-click the certificate name to load it. Then select Continue.

  8. After the portal creates the certificate, select the Download button. Save the certificate, and remember the location to which it's saved.

    Generated certificate download page

    The certificate is downloaded and saved to your computer in your Downloads folder.

    Locate certificate file in the Downloads folder

    Note

    By default, the downloaded development certificate is named aps_development.cer.

  9. Double-click the downloaded push certificate aps_development.cer. This action installs the new certificate in the Keychain, as shown in the following image:

    Keychain access certificates list showing new certificate

    Note

    Although the name in your certificate might be different, the name will be prefixed with Apple Development iOS Push Services and have the appropriate bundle identifier associated with it.

  10. In Keychain Access, Control + Click on the new push certificate that you created in the Certificates category. Select Export, name the file, select the p12 format, and then select Save.

    Export certificate as p12 format

    You can choose to protect the certificate with a password, but a password is optional. Click OK if you want to bypass password creation. Make a note of the file name and location of the exported p12 certificate. They're used to enable authentication with APNs.

    Note

    Your p12 file name and location might be different than what is pictured in this tutorial.

OPTION 2: Creating a p8 certificate that can be used for token-based authentication
  1. Make note of the following details:

    • App ID Prefix (Team ID)
    • Bundle ID
  2. Back in Certificates, Identifiers & Profiles, click Keys.

    Note

    If you already have a key configured for APNS, you can re-use the p8 certificate that you downloaded right after it was created. If so, you can ignore steps 3 through 5.

  3. Click the + button (or the Create a key button) to create a new key.

  4. Provide a suitable Key Name value, then check the Apple Push Notifications service (APNS) option, and then click Continue, followed by Register on the next screen.

  5. Click Download and then move the p8 file (prefixed with AuthKey_) to a secure local directory, then click Done.

    Note

    Be sure to keep your p8 file in a secure place (and save a backup). After downloading your key, it cannot be re-downloaded as the server copy is removed.

  6. On Keys, click on the key that you created (or an existing key if you have chosen to use that instead).

  7. Make note of the Key ID value.

  8. Open your p8 certificate in a suitable application of your choice such as Visual Studio Code. Make note of the key value (between -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----).

    -----BEGIN PRIVATE KEY-----
    <key_value>
    -----END PRIVATE KEY-----

    Note

    This is the token value that will be used later to configure Notification Hub.

At the end of these steps, you should have the following information for use later in Configure your notification hub with APNS information:

  • Team ID (see step 1)
  • Bundle ID (see step 1)
  • Key ID (see step 7)
  • Token value (p8 key value obtained in step 8)

Create a provisioning profile for the app

  1. Return to the iOS Provisioning Portal, select Certificates, Identifiers & Profiles, select Profiles from the left menu, and then select + to create a new profile. The Register a New Provisioning Profile screen appears.

  2. Select iOS App Development under Development as the provisioning profile type, and then select Continue.

    Provisioning profile list

  3. Next, select the app ID you created from the App ID drop-down list, and select Continue.

    Select the App ID

  4. In the Select certificates window, select the development certificate that you use for code signing, and select Continue.

    Note

    This certificate is not the push certificate you created in the previous step. This is your development certificate. If one does not exist, you must create it since this is a prerequisite for this tutorial. Developer certificates can be created in the Apple Developer Portal, via Xcode or in Visual Studio.

  5. Return to the Certificates, Identifiers & Profiles page, select Profiles from the left menu, and then select + to create a new profile. The Register a New Provisioning Profile screen appears.

  6. In the Select certificates window, select the development certificate that you created. Then select Continue.

  7. Next, select the devices to use for testing, and select Continue.

  8. Finally, choose a name for the profile in Provisioning Profile Name, and select Generate.

    Choose a provisioning profile name

  9. When the new provisioning profile is created, select Download. Remember the location to which it's saved.

  10. Browse to the location of the provisioning profile, and then double-click it to install it on your development machine.

Create a Notification Hub

In this section, you create a notification hub and configure authentication with APNS. You can use a p12 push certificate or token-based authentication. If you want to use a notification hub that you've already created, you can skip to step 5.

  1. Sign in to Azure.

  2. Click Create a resource, then search for and choose Notification Hub, then click Create.

  3. Update the following fields, then click Create:

    BASIC DETAILS

    Subscription: Choose the target Subscription from the drop-down list
    Resource Group: Create a new Resource Group (or pick an existing one)

    NAMESPACE DETAILS

    Notification Hub Namespace: Enter a globally unique name for the Notification Hub namespace

    Note

    Ensure the Create new option is selected for this field.

    NOTIFICATION HUB DETAILS

    Notification Hub: Enter a name for the Notification Hub
    Location: Choose a suitable location from the drop-down list
    Pricing Tier: Keep the default Free option

    Note

    Unless you have reached the maximum number of hubs on the free tier.

  4. Once the Notification Hub has been provisioned, navigate to that resource.

  5. Navigate to your new Notification Hub.

  6. Select Access Policies from the list (under MANAGE).

  7. Make note of the Policy Name values along with their corresponding Connection String values.

Configure your Notification Hub with APNS information

Under Notification Services, select Apple then follow the appropriate steps based on the approach you chose previously in the Creating a Certificate for Notification Hubs section.

Note

Use the Production for Application Mode only if you want to send push notifications to users who purchased your app from the store.

OPTION 1: Using a .p12 push certificate

  1. Select Certificate.

  2. Select the file icon.

  3. Select the .p12 file that you exported earlier, and then select Open.

  4. If necessary, specify the correct password.

  5. Select Sandbox mode.

  6. Select Save.

OPTION 2: Using token-based authentication

  1. Select Token.

  2. Enter the following values that you acquired earlier:

    • Key ID
    • Bundle ID
    • Team ID
    • Token
  3. Choose Sandbox.

  4. Select Save.

Configure your notification hub with FCM information

  1. Select Google (GCM/FCM) in the Settings section on the left menu.
  2. Enter the server key you noted down from the Google Firebase Console.
  3. Select Save on the toolbar.

Create an ASP.NET Core Web API backend application

In this section, you create the ASP.NET Core Web API backend to handle device registration and the sending of notifications to the Flutter mobile app.

Create a web project

  1. In Visual Studio, select File > New Solution.

  2. Select .NET Core > App > ASP.NET Core > API > Next.

  3. In the Configure your new ASP.NET Core Web API dialog, select Target Framework of .NET Core 3.1.

  4. Enter PushDemoApi for the Project Name and then select Create.

  5. Start debugging (Command + Enter) to test the templated app.

    Note

    The templated app is configured to use the WeatherForecastController as the launchUrl. This is set in Properties > launchSettings.json.

    If you are prompted with an Invalid development certificate found message:

    1. Click Yes to agree to running the 'dotnet dev-certs https' tool to fix this. The 'dotnet dev-certs https' tool then prompt you to enter a password for the certificate and the password for your Keychain.

    2. Click Yes when prompted to Install and trust the new certificate, then enter the password for your Keychain.

  6. Expand the Controllers folder, then delete WeatherForecastController.cs.

  7. Delete WeatherForecast.cs.

  8. Set up local configuration values using the Secret Manager tool. Decoupling the secrets from the solution ensures that they don't end up in source control. Open Terminal then go to the directory of the project file and run the following commands:

    dotnet user-secrets init
    dotnet user-secrets set "NotificationHub:Name" <value>
    dotnet user-secrets set "NotificationHub:ConnectionString" <value>
    

    Replace the placeholder values with your own notification hub name and connection string values. You made a note of them in the create a notification hub section. Otherwise, you can look them up in Azure.

    NotificationHub:Name:
    See Name in the Essentials summary at the top of Overview.

    NotificationHub:ConnectionString:
    See DefaultFullSharedAccessSignature in Access Policies

    Note

    For production scenarios, you can look at options such as Azure KeyVault to securely store the connection string. For simplicity, the secrets will be added to the Azure App Service application settings.

Authenticate clients using an API Key (Optional)

API keys aren't as secure as tokens, but will suffice for the purposes of this tutorial. An API key can be configured easily via the ASP.NET Middleware.

  1. Add the API key to the local configuration values.

    dotnet user-secrets set "Authentication:ApiKey" <value>
    

    Note

    You should replace the placeholder value with your own and make a note of it.

  2. Control + Click on the PushDemoApi project, choose New Folder from the Add menu, then click Add using Authentication as the Folder Name.

  3. Control + Click on the Authentication folder, then choose New File... from the Add menu.

  4. Select General > Empty Class, enter ApiKeyAuthOptions.cs for the Name, then click New adding the following implementation.

    using Microsoft.AspNetCore.Authentication;
    
    namespace PushDemoApi.Authentication
    {
        public class ApiKeyAuthOptions : AuthenticationSchemeOptions
        {
            public const string DefaultScheme = "ApiKey";
            public string Scheme => DefaultScheme;
            public string ApiKey { get; set; }
        }
    }
    
  5. Add another Empty Class to the Authentication folder called ApiKeyAuthHandler.cs, then add the following implementation.

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Security.Claims;
    using System.Text.Encodings.Web;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Authentication;
    using Microsoft.Extensions.Logging;
    using Microsoft.Extensions.Options;
    
    namespace PushDemoApi.Authentication
    {
        public class ApiKeyAuthHandler : AuthenticationHandler<ApiKeyAuthOptions>
        {
            const string ApiKeyIdentifier = "apikey";
    
            public ApiKeyAuthHandler(
                IOptionsMonitor<ApiKeyAuthOptions> options,
                ILoggerFactory logger,
                UrlEncoder encoder,
                ISystemClock clock)
                : base(options, logger, encoder, clock) {}
    
            protected override Task<AuthenticateResult> HandleAuthenticateAsync()
            {
                string key = string.Empty;
    
                if (Request.Headers[ApiKeyIdentifier].Any())
                {
                    key = Request.Headers[ApiKeyIdentifier].FirstOrDefault();
                }
                else if (Request.Query.ContainsKey(ApiKeyIdentifier))
                {
                    if (Request.Query.TryGetValue(ApiKeyIdentifier, out var queryKey))
                        key = queryKey;
                }
    
                if (string.IsNullOrWhiteSpace(key))
                    return Task.FromResult(AuthenticateResult.Fail("No api key provided"));
    
                if (!string.Equals(key, Options.ApiKey, StringComparison.Ordinal))
                    return Task.FromResult(AuthenticateResult.Fail("Invalid api key."));
    
                var identities = new List<ClaimsIdentity> {
                    new ClaimsIdentity("ApiKeyIdentity")
                };
    
                var ticket = new AuthenticationTicket(
                    new ClaimsPrincipal(identities), Options.Scheme);
    
                return Task.FromResult(AuthenticateResult.Success(ticket));
            }
        }
    }
    

    Note

    An Authentication Handler is a type that implements the behavior of a scheme, in this case a custom API Key scheme.

  6. Add another Empty Class to the Authentication folder called ApiKeyAuthenticationBuilderExtensions.cs, then add the following implementation.

    using System;
    using Microsoft.AspNetCore.Authentication;
    
    namespace PushDemoApi.Authentication
    {
        public static class AuthenticationBuilderExtensions
        {
            public static AuthenticationBuilder AddApiKeyAuth(
                this AuthenticationBuilder builder,
                Action<ApiKeyAuthOptions> configureOptions)
            {
                return builder
                    .AddScheme<ApiKeyAuthOptions, ApiKeyAuthHandler>(
                        ApiKeyAuthOptions.DefaultScheme,
                        configureOptions);
            }
        }
    }
    

    Note

    This extension method simplifies the middleware configuration code in Startup.cs making it more readable and generally easier to follow.

  7. In Startup.cs, update the ConfigureServices method to configure the API Key authentication below the call to the services.AddControllers method.

    using PushDemoApi.Authentication;
    using PushDemoApi.Models;
    using PushDemoApi.Services;
    
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
    
        services.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = ApiKeyAuthOptions.DefaultScheme;
            options.DefaultChallengeScheme = ApiKeyAuthOptions.DefaultScheme;
        }).AddApiKeyAuth(Configuration.GetSection("Authentication").Bind);
    }
    
  8. Still in Startup.cs, update the Configure method to call the UseAuthentication and UseAuthorization extension methods on the app's IApplicationBuilder. Ensure those methods are called after UseRouting and before app.UseEndpoints.

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
    
        app.UseHttpsRedirection();
    
        app.UseRouting();
    
        app.UseAuthentication();
    
        app.UseAuthorization();
    
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
    

    Note

    Calling UseAuthentication registers the middleware which uses the previously registered authentication schemes (from ConfigureServices). This must be called before any middleware that depends on users being authenticated.

Add dependencies and configure services

ASP.NET Core supports the dependency injection (DI) software design pattern, which is a technique for achieving Inversion of Control (IoC) between classes and their dependencies.

Use of the notification hub and the Notification Hubs SDK for backend operations is encapsulated within a service. The service is registered and made available through a suitable abstraction.

  1. Control + Click on the Dependencies folder, then choose Manage NuGet Packages....

  2. Search for Microsoft.Azure.NotificationHubs and ensure it's checked.

  3. Click Add Packages, then click Accept when prompted to accept the license terms.

  4. Control + Click on the PushDemoApi project, choose New Folder from the Add menu, then click Add using Models as the Folder Name.

  5. Control + Click on the Models folder, then choose New File... from the Add menu.

  6. Select General > Empty Class, enter PushTemplates.cs for the Name, then click New adding the following implementation.

    namespace PushDemoApi.Models
    {
        public class PushTemplates
        {
            public class Generic
            {
                public const string Android = "{ \"notification\": { \"title\" : \"PushDemo\", \"body\" : \"$(alertMessage)\"}, \"data\" : { \"action\" : \"$(alertAction)\" } }";
                public const string iOS = "{ \"aps\" : {\"alert\" : \"$(alertMessage)\"}, \"action\" : \"$(alertAction)\" }";
            }
    
            public class Silent
            {
                public const string Android = "{ \"data\" : {\"message\" : \"$(alertMessage)\", \"action\" : \"$(alertAction)\"} }";
                public const string iOS = "{ \"aps\" : {\"content-available\" : 1, \"apns-priority\": 5, \"sound\" : \"\", \"badge\" : 0}, \"message\" : \"$(alertMessage)\", \"action\" : \"$(alertAction)\" }";
            }
        }
    }
    

    Note

    This class contains the tokenized notification payloads for the generic and silent notifications required by this scenario. The payloads are defined outside of the Installation to allow experimentation without having to update existing installations via the service. Handling changes to installations in this way is out of scope for this tutorial. For production, consider custom templates.

  7. Add another Empty Class to the Models folder called DeviceInstallation.cs, then add the following implementation.

    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    
    namespace PushDemoApi.Models
    {
        public class DeviceInstallation
        {
            [Required]
            public string InstallationId { get; set; }
    
            [Required]
            public string Platform { get; set; }
    
            [Required]
            public string PushChannel { get; set; }
    
            public IList<string> Tags { get; set; } = Array.Empty<string>();
        }
    }
    
  8. Add another Empty Class to the Models folder called NotificationRequest.cs, then add the following implementation.

    using System;
    
    namespace PushDemoApi.Models
    {
        public class NotificationRequest
        {
            public string Text { get; set; }
            public string Action { get; set; }
            public string[] Tags { get; set; } = Array.Empty<string>();
            public bool Silent { get; set; }
        }
    }
    
  9. Add another Empty Class to the Models folder called NotificationHubOptions.cs, then add the following implementation.

    using System.ComponentModel.DataAnnotations;
    
    namespace PushDemoApi.Models
    {
        public class NotificationHubOptions
        {
            [Required]
            public string Name { get; set; }
    
            [Required]
            public string ConnectionString { get; set; }
        }
    }
    
  10. Add a new folder to the PushDemoApi project called Services.

  11. Add an Empty Interface to the Services folder called INotificationService.cs, then add the following implementation.

    using System.Threading;
    using System.Threading.Tasks;
    using PushDemoApi.Models;
    
    namespace PushDemoApi.Services
    {
        public interface INotificationService
        {
            Task<bool> CreateOrUpdateInstallationAsync(DeviceInstallation deviceInstallation, CancellationToken token);
            Task<bool> DeleteInstallationByIdAsync(string installationId, CancellationToken token);
            Task<bool> RequestNotificationAsync(NotificationRequest notificationRequest, CancellationToken token);
        }
    }
    
  12. Add an Empty Class to the Services folder called NotificationHubsService.cs, then add the following code to implement the INotificationService interface:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Azure.NotificationHubs;
    using Microsoft.Extensions.Logging;
    using Microsoft.Extensions.Options;
    using PushDemoApi.Models;
    
    namespace PushDemoApi.Services
    {
        public class NotificationHubService : INotificationService
        {
            readonly NotificationHubClient _hub;
            readonly Dictionary<string, NotificationPlatform> _installationPlatform;
            readonly ILogger<NotificationHubService> _logger;
    
            public NotificationHubService(IOptions<NotificationHubOptions> options, ILogger<NotificationHubService> logger)
            {
                _logger = logger;
                _hub = NotificationHubClient.CreateClientFromConnectionString(
                    options.Value.ConnectionString,
                    options.Value.Name);
    
                _installationPlatform = new Dictionary<string, NotificationPlatform>
                {
                    { nameof(NotificationPlatform.Apns).ToLower(), NotificationPlatform.Apns },
                    { nameof(NotificationPlatform.Fcm).ToLower(), NotificationPlatform.Fcm }
                };
            }
    
            public async Task<bool> CreateOrUpdateInstallationAsync(DeviceInstallation deviceInstallation, CancellationToken token)
            {
                if (string.IsNullOrWhiteSpace(deviceInstallation?.InstallationId) ||
                    string.IsNullOrWhiteSpace(deviceInstallation?.Platform) ||
                    string.IsNullOrWhiteSpace(deviceInstallation?.PushChannel))
                    return false;
    
                var installation = new Installation()
                {
                    InstallationId = deviceInstallation.InstallationId,
                    PushChannel = deviceInstallation.PushChannel,
                    Tags = deviceInstallation.Tags
                };
    
                if (_installationPlatform.TryGetValue(deviceInstallation.Platform, out var platform))
                    installation.Platform = platform;
                else
                    return false;
    
                try
                {
                    await _hub.CreateOrUpdateInstallationAsync(installation, token);
                }
                catch
                {
                    return false;
                }
    
                return true;
            }
    
            public async Task<bool> DeleteInstallationByIdAsync(string installationId, CancellationToken token)
            {
                if (string.IsNullOrWhiteSpace(installationId))
                    return false;
    
                try
                {
                    await _hub.DeleteInstallationAsync(installationId, token);
                }
                catch
                {
                    return false;
                }
    
                return true;
            }
    
            public async Task<bool> RequestNotificationAsync(NotificationRequest notificationRequest, CancellationToken token)
            {
                if ((notificationRequest.Silent &&
                    string.IsNullOrWhiteSpace(notificationRequest?.Action)) ||
                    (!notificationRequest.Silent &&
                    (string.IsNullOrWhiteSpace(notificationRequest?.Text)) ||
                    string.IsNullOrWhiteSpace(notificationRequest?.Action)))
                    return false;
    
                var androidPushTemplate = notificationRequest.Silent ?
                    PushTemplates.Silent.Android :
                    PushTemplates.Generic.Android;
    
                var iOSPushTemplate = notificationRequest.Silent ?
                    PushTemplates.Silent.iOS :
                    PushTemplates.Generic.iOS;
    
                var androidPayload = PrepareNotificationPayload(
                    androidPushTemplate,
                    notificationRequest.Text,
                    notificationRequest.Action);
    
                var iOSPayload = PrepareNotificationPayload(
                    iOSPushTemplate,
                    notificationRequest.Text,
                    notificationRequest.Action);
    
                try
                {
                    if (notificationRequest.Tags.Length == 0)
                    {
                        // This will broadcast to all users registered in the notification hub
                        await SendPlatformNotificationsAsync(androidPayload, iOSPayload, token);
                    }
                    else if (notificationRequest.Tags.Length <= 20)
                    {
                        await SendPlatformNotificationsAsync(androidPayload, iOSPayload, notificationRequest.Tags, token);
                    }
                    else
                    {
                        var notificationTasks = notificationRequest.Tags
                            .Select((value, index) => (value, index))
                            .GroupBy(g => g.index / 20, i => i.value)
                            .Select(tags => SendPlatformNotificationsAsync(androidPayload, iOSPayload, tags, token));
    
                        await Task.WhenAll(notificationTasks);
                    }
    
                    return true;
                }
                catch (Exception e)
                {
                    _logger.LogError(e, "Unexpected error sending notification");
                    return false;
                }
            }
    
            string PrepareNotificationPayload(string template, string text, string action) => template
                .Replace("$(alertMessage)", text, StringComparison.InvariantCulture)
                .Replace("$(alertAction)", action, StringComparison.InvariantCulture);
    
            Task SendPlatformNotificationsAsync(string androidPayload, string iOSPayload, CancellationToken token)
            {
                var sendTasks = new Task[]
                {
                    _hub.SendFcmNativeNotificationAsync(androidPayload, token),
                    _hub.SendAppleNativeNotificationAsync(iOSPayload, token)
                };
    
                return Task.WhenAll(sendTasks);
            }
    
            Task SendPlatformNotificationsAsync(string androidPayload, string iOSPayload, IEnumerable<string> tags, CancellationToken token)
            {
                var sendTasks = new Task[]
                {
                    _hub.SendFcmNativeNotificationAsync(androidPayload, tags, token),
                    _hub.SendAppleNativeNotificationAsync(iOSPayload, tags, token)
                };
    
                return Task.WhenAll(sendTasks);
            }
        }
    }
    

    Note

    The tag expression provided to SendTemplateNotificationAsync is limited to 20 tags. It is limited to 6 for most operators but the expression contains only ORs (||) in this case. If there are more than 20 tags in the request then they must be split into multiple requests. See the Routing and Tag Expressions documentation for more detail.

  13. In Startup.cs, update the ConfigureServices method to add the NotificationHubsService as a singleton implementation of INotificationService.

    
    using PushDemoApi.Models;
    using PushDemoApi.Services;
    
    public void ConfigureServices(IServiceCollection services)
    {
        ...
    
        services.AddSingleton<INotificationService, NotificationHubService>();
    
        services.AddOptions<NotificationHubOptions>()
            .Configure(Configuration.GetSection("NotificationHub").Bind)
            .ValidateDataAnnotations();
    }
    

Create the notifications API

  1. Control + Click on the Controllers folder, then choose New File... from the Add menu.

  2. Select ASP.NET Core > Web API Controller Class, enter NotificationsController for the Name, then click New.

    Note

    If you're following with Visual Studio 2019, choose the API Controller with read/write actions template.

  3. Add the following namespaces to the top of the file.

    using System.ComponentModel.DataAnnotations;
    using System.Net;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    using PushDemoApi.Models;
    using PushDemoApi.Services;
    
  4. Update the templated controller so it derives from ControllerBase and is decorated with the ApiController attribute.

    [ApiController]
    [Route("api/[controller]")]
    public class NotificationsController : ControllerBase
    {
        // Templated methods here
    }
    

    Note

    The Controller base class provides support for views but this is not needed in this case and so ControllerBase can be used instead. If you're following with Visual Studio 2019, you can skip this step.

  5. If you chose to complete the Authenticate clients using an API Key section, you should decorate the NotificationsController with the Authorize attribute as well.

    [Authorize]
    
  6. Update the constructor to accept the registered instance of INotificationService as an argument and assign it to a readonly member.

    readonly INotificationService _notificationService;
    
    public NotificationsController(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }
    
  7. In launchSettings.json (within the Properties folder), change the launchUrl from weatherforecast to api/notifications to match the URL specified in the RegistrationsController Route attribute.

  8. Start debugging (Command + Enter) to validate the app is working with the new NotificationsController and returns a 401 Unauthorized status.

    Note

    Visual Studio may not automatically launch the app in the browser. You will use Postman to test the API from this point on.

  9. On a new Postman tab, set the request to GET. Enter the address below replacing the placeholder <applicationUrl> with the https applicationUrl found in Properties > launchSettings.json.

    <applicationUrl>/api/notifications
    

    Note

    The applicationUrl should be 'https://localhost:5001' for the default profile. If you're using IIS (default in Visual Studio 2019 on Windows), you should use the applicationUrl specified in the iisSettings item instead. You will receive a 404 response if the address is incorrect.

  10. If you chose to complete the Authenticate clients using an API Key section, be sure to configure the request headers to include your apikey value.

    Key Value
    apikey <your_api_key>
  11. Click the Send button.

    Note

    You should receive a 200 OK status with some JSON content.

    If you receive an SSL certificate verification warning, you can switch the request SSL certificate verification Postman setting off in the Settings.

  12. Replace the templated class methods in NotificationsController.cs with the following code.

    [HttpPut]
    [Route("installations")]
    [ProducesResponseType((int)HttpStatusCode.OK)]
    [ProducesResponseType((int)HttpStatusCode.BadRequest)]
    [ProducesResponseType((int)HttpStatusCode.UnprocessableEntity)]
    public async Task<IActionResult> UpdateInstallation(
        [Required]DeviceInstallation deviceInstallation)
    {
        var success = await _notificationService
            .CreateOrUpdateInstallationAsync(deviceInstallation, HttpContext.RequestAborted);
    
        if (!success)
            return new UnprocessableEntityResult();
    
        return new OkResult();
    }
    
    [HttpDelete()]
    [Route("installations/{installationId}")]
    [ProducesResponseType((int)HttpStatusCode.OK)]
    [ProducesResponseType((int)HttpStatusCode.BadRequest)]
    [ProducesResponseType((int)HttpStatusCode.UnprocessableEntity)]
    public async Task<ActionResult> DeleteInstallation(
        [Required][FromRoute]string installationId)
    {
        var success = await _notificationService
            .DeleteInstallationByIdAsync(installationId, CancellationToken.None);
    
        if (!success)
            return new UnprocessableEntityResult();
    
        return new OkResult();
    }
    
    [HttpPost]
    [Route("requests")]
    [ProducesResponseType((int)HttpStatusCode.OK)]
    [ProducesResponseType((int)HttpStatusCode.BadRequest)]
    [ProducesResponseType((int)HttpStatusCode.UnprocessableEntity)]
    public async Task<IActionResult> RequestPush(
        [Required]NotificationRequest notificationRequest)
    {
        if ((notificationRequest.Silent &&
            string.IsNullOrWhiteSpace(notificationRequest?.Action)) ||
            (!notificationRequest.Silent &&
            string.IsNullOrWhiteSpace(notificationRequest?.Text)))
            return new BadRequestResult();
    
        var success = await _notificationService
            .RequestNotificationAsync(notificationRequest, HttpContext.RequestAborted);
    
        if (!success)
            return new UnprocessableEntityResult();
    
        return new OkResult();
    }
    

Create the API app

You now create an API App in Azure App Service for hosting the backend service.

  1. Sign in to the Azure portal.

  2. Click Create a resource, then search for and choose API App, then click Create.

  3. Update the following fields, then click Create.

    App name:
    Enter a globally unique name for the API App

    Subscription:
    Choose the same target Subscription you created the notification hub in.

    Resource Group:
    Choose the same Resource Group you created the notification hub in.

    App Service Plan/Location:
    Create a new App Service Plan

    Note

    Change from the default option to a plan that includes SSL support. Otherwise, you will need to take the appropriate steps when working with the mobile app to prevent http requests from getting blocked.

    Application Insights:
    Keep the suggested option (a new resource will be created using that name) or pick an existing resource.

  4. Once the API App has been provisioned, navigate to that resource.

  5. Make note of the URL property in the Essentials summary at the top of the Overview. This URL is your backend endpoint that will be used later in this tutorial.

    Note

    The URL uses the API app name that you specified earlier, with the format https://<app_name>.azurewebsites.net.

  6. Select Configuration from the list (under Settings).

  7. For each of the settings below, click New application setting to enter the Name and a Value, then click OK.

    Name Value
    Authentication:ApiKey <api_key_value>
    NotificationHub:Name <hub_name_value>
    NotificationHub:ConnectionString <hub_connection_string_value>

    Note

    These are the same settings you defined previously in the user settings. You should be able to copy these over. The Authentication:ApiKey setting is required only if you chose to to complete the Authenticate clients using an API Key section. For production scenarios, you can look at options such as Azure KeyVault. These have been added as application settings for simplicity in this case.

  8. Once all application settings have been added click Save, then Continue.

Publish the backend service

Next, you deploy the app to the API App to make it accessible from all devices.

Note

The following steps are specific to Visual Studio for Mac. If you're following with Visual Studio 2019 on Windows, the publishing flow will be different. See Publish to Azure App Service on Windows.

  1. Change your configuration from Debug to Release if you haven't already done so.

  2. Control + Click the PushDemoApi project, and then choose Publish to Azure... from the Publish menu.

  3. Follow the auth flow if prompted to do so. Use the account that you used in the previous create the API App section.

  4. Select the Azure App Service API App you created previously from the list as your publish target, and then click Publish.

After you've completed the wizard, it publishes the app to Azure and then opens the app. Make a note of the URL if you haven't done so already. This URL is your backend endpoint that is used later in this tutorial.

Validating the published API

  1. In Postman open a new tab, set the request to PUT and enter the address below. Replace the placeholder with the base address you made note of in the previous publish the backend service section.

    https://<app_name>.azurewebsites.net/api/notifications/installations
    

    Note

    The base address should be in the format https://<app_name>.azurewebsites.net/

  2. If you chose to complete the Authenticate clients using an API Key section, be sure to configure the request headers to include your apikey value.

    Key Value
    apikey <your_api_key>
  3. Choose the raw option for the Body, then choose JSON from the list of format options, and then include some placeholder JSON content:

    {}
    
  4. Click Send.

    Note

    You should receive a 422 UnprocessableEntity status from the service.

  5. Do steps 1-4 again but this time specifying the requests endpoint to validate you receive a 400 Bad Request response.

    https://<app_name>.azurewebsites.net/api/notifications/requests
    

Note

It is not yet possible to test the API using valid request data since this will require platform-specific information from the client mobile app.

Create a cross-platform Flutter application

In this section, you build a Flutter mobile application implementing push notifications in a cross-platform manner.

It enables you to register and deregister from a notification hub via the backend service that you created.

An alert is displayed when an action is specified and the app is in the foreground. Otherwise, notifications appear in notification center.

Note

You would typically perform the registration (and deregistration) actions during the appropriate point in the application lifecycle (or as part of your first-run experience perhaps) without explicit user register/deregister inputs. However, this example will require explicit user input to allow this functionality to be explored and tested more easily.

Create the Flutter solution

  1. Open a new instance of Visual Studio Code.

  2. Open the Command Palette (Shift + Command + P).

  3. Select the Flutter: New Project command then press Enter.

  4. Enter push_demo for the Project Name and then select a Project location.

  5. When prompted to do so, choose Get Packages.

  6. Control + Click on the kotlin folder (under app > src > main), then choose Reveal in Finder. Then, rename the child folders (under the kotlin folder) to com, <your_organization>, and pushdemo respectively.

    Note

    When using the Visual Studio Code template these folders default to com, example, <project_name>. Assuming mobcat is used for the organization, the folder structure should indicatively appear as:

    • kotlin
      • com
        • mobcat
          • pushdemo
  7. Back in Visual Studio Code, update the applicationId value in android > app > build.gradle to com.<your_organization>.pushdemo.

    Note

    You should use your own organization name for the <your_organization> placeholder. For example, using mobcat as the organization will result in a package name value of com.mobcat.pushdemo.

  8. Update the package attribute in the AndroidManifest.xml files, under src > debug, src > main, and src > profile respectively. Ensure the values match the applicationId you used in the previous step.

    <manifest
        xmlns:android="http://schemas.android.com/apk/res/android"
        package="com.<your_organization>.pushdemo>">
        ...
    </manifest>
    
  9. Update the android:label attribute in the AndroidManifest.xml file under src > main to PushDemo. Then, add the android:allowBackup attribute, directly under android:label, setting its value to false.

    <application
        android:name="io.flutter.app.FlutterApplication"
        android:label="PushDemo"
        android:allowBackup="false"
        android:icon="@mipmap/ic_launcher">
        ...
    </application>
    
  10. Open the app-level build.gradle file (android > app > build.gradle), then update the compileSdkVersion (from the android section) to use API 29. Then, update the minSdkVersion and targetSdkVersion values (from the defaultConfig section), to 26 and 29 respectively.

    Note

    Only those devices running API level 26 and above are supported for the purposes of this tutorial however you can extend it to support devices running older versions.

  11. Control + Click on the ios folder, then choose Open in Xcode.

  12. In Xcode, click on Runner (the xcodeproj at the top, not the folder). Then, select the Runner target and select the General tab. With the All build configuration selected, update the Bundle Identifier to com.<your_organization>.PushDemo.

    Note

    You should use your own organization name for the <your_organization> placeholder. For example, using mobcat as the organization will result in a Bundle Identifier value of com.mobcat.PushDemo.

  13. Click Info.plist then update the Bundle name value to PushDemo

  14. Close Xcode and return to Visual Studio Code.

  15. Back in Visual Studio Code, open pubspec.yaml, add the http and flutter_secure_storage Dart packages as dependencies. Then, save the file and click Get Packages when prompted to do so.

    dependencies:
      flutter:
        sdk: flutter
    
      http: ^0.12.1
      flutter_secure_storage: ^3.3.3
    
  16. In Terminal, change directory to the ios folder (for your Flutter project). Then, execute the pod install command to install new pods (required by the flutter_secure_storage package).

  17. Control + Click on the lib folder, then choose New File from the menu using main_page.dart as the filename. Then, add the following code.

    import 'package:flutter/material.dart';
    
    class MainPage extends StatefulWidget {
      @override
      _MainPageState createState() => _MainPageState();
    }
    
    class _MainPageState extends State<MainPage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: SafeArea(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[],
            )
          )
        );
      }
    }
    
  18. In main.dart, replace the templated code with the following.

    import 'package:flutter/material.dart';
    import 'package:push_demo/main_page.dart';
    
    final navigatorKey = GlobalKey<NavigatorState>();
    
    void main() => runApp(MaterialApp(home: MainPage(), navigatorKey: navigatorKey));
    
  19. In Terminal, build and run the app on each target platform to test the templated app runs on your device(s). Make sure that supported devices are connected.

    flutter run
    

Implement the cross-platform components

  1. Control + Click on the lib folder, then choose New Folder from the menu using models as the Folder Name.

  2. Control + Click on the models folder, then choose New File from the menu using device_installation.dart as the filename. Then, add the following code.

    class DeviceInstallation {
        final String deviceId;
        final String platform;
        final String token;
        final List<String> tags;
    
        DeviceInstallation(this.deviceId, this.platform, this.token, this.tags);
    
        DeviceInstallation.fromJson(Map<String, dynamic> json)
          : deviceId = json['installationId'],
            platform = json['platform'],
            token = json['pushChannel'],
            tags = json['tags'];
    
        Map<String, dynamic> toJson() =>
        {
          'installationId': deviceId,
          'platform': platform,
          'pushChannel': token,
          'tags': tags,
        };
    }
    
  3. Add a new file to the models folder called push_demo_action.dart defining the enumeration of actions being supported in this example.

    enum PushDemoAction {
      actionA,
      actionB,
    }
    
  4. Add a new folder to the project called services then add a new file to that folder called device_installation_service.dart with the following implementation.

    import 'package:flutter/services.dart';
    
    class DeviceInstallationService {
      static const deviceInstallation = const MethodChannel('com.<your_organization>.pushdemo/deviceinstallation');
      static const String getDeviceIdChannelMethod = "getDeviceId";
      static const String getDeviceTokenChannelMethod = "getDeviceToken";
      static const String getDevicePlatformChannelMethod = "getDevicePlatform";
    
      Future<String> getDeviceId() {
        return deviceInstallation.invokeMethod(getDeviceIdChannelMethod);
      }
    
      Future<String> getDeviceToken() {
        return deviceInstallation.invokeMethod(getDeviceTokenChannelMethod);
      }
    
      Future<String> getDevicePlatform() {
        return deviceInstallation.invokeMethod(getDevicePlatformChannelMethod);
      }
    }
    

    Note

    You should use your own organization name for the <your_organization> placeholder. For example, using mobcat as the organization will result in a MethodChannel name of com.mobcat.pushdemo/deviceinstallation.

    This class encapsulates working with the underlying native platform to acquire the requisite device installation details. A MethodChannel facilitates bidirectional asynchronous communication with the underlying native platforms. The platform-specific counterpart for this channel will be created on the in later steps.

  5. Add another file to that folder called notification_action_service.dart with the following implementation.

    import 'package:flutter/services.dart';
    import 'dart:async';
    import 'package:push_demo/models/push_demo_action.dart';
    
    class NotificationActionService {
      static const notificationAction =
          const MethodChannel('com.<your_organization>.pushdemo/notificationaction');
      static const String triggerActionChannelMethod = "triggerAction";
      static const String getLaunchActionChannelMethod = "getLaunchAction";
    
      final actionMappings = {
        'action_a' : PushDemoAction.actionA,
        'action_b' : PushDemoAction.actionB
      };
    
      final actionTriggeredController = StreamController.broadcast();
    
      NotificationActionService() {
        notificationAction
            .setMethodCallHandler(handleNotificationActionCall);
      }
    
      Stream get actionTriggered => actionTriggeredController.stream;
    
      Future<void> triggerAction({action: String}) async {
    
        if (!actionMappings.containsKey(action)) {
          return;
        }
    
        actionTriggeredController.add(actionMappings[action]);
      }
    
      Future<void> checkLaunchAction() async {
        final launchAction = await notificationAction.invokeMethod(getLaunchActionChannelMethod) as String;
    
        if (launchAction != null) {
          triggerAction(action: launchAction);
        }
      }
    
      Future<void> handleNotificationActionCall(MethodCall call) async {
        switch (call.method) {
          case triggerActionChannelMethod:
            return triggerAction(action: call.arguments as String);
          default:
            throw MissingPluginException();
            break;
        }
      }
    }
    

    Note

    This is used as a simple mechanism to centralize the handling of notification actions so they can be handled in a cross-platform manner using a strongly-typed enumeration. The service enables the underlying native platform to trigger an action, when one is specified in the notification payload. It also enables the common code to retrospectively check whether an action was specified during the application launch once Flutter is ready to process it. For example, when the app is launched by tapping on a notification from notification center.

  6. Add a new file to the services folder called notification_registration_service.dart with the following implementation.

    import 'dart:convert';
    import 'package:flutter/services.dart';
    import 'package:http/http.dart' as http;
    import 'package:push_demo/services/device_installation_service.dart';
    import 'package:push_demo/models/device_installation.dart';
    import 'package:flutter_secure_storage/flutter_secure_storage.dart';
    
    class NotificationRegistrationService {
      static const notificationRegistration =
          const MethodChannel('com.<your_organization>.pushdemo/notificationregistration');
    
      static const String refreshRegistrationChannelMethod = "refreshRegistration";
      static const String installationsEndpoint = "api/notifications/installations";
      static const String cachedDeviceTokenKey = "cached_device_token";
      static const String cachedTagsKey = "cached_tags";
    
      final deviceInstallationService = DeviceInstallationService();
      final secureStorage = FlutterSecureStorage();
    
      String baseApiUrl;
      String apikey;
    
      NotificationRegistrationService(this.baseApiUrl, this.apikey) {
        notificationRegistration
            .setMethodCallHandler(handleNotificationRegistrationCall);
      }
    
      String get installationsUrl => "$baseApiUrl$installationsEndpoint";
    
      Future<void> deregisterDevice() async {
        final cachedToken = await secureStorage.read(key: cachedDeviceTokenKey);
        final serializedTags = await secureStorage.read(key: cachedTagsKey);
    
        if (cachedToken == null || serializedTags == null) {
          return;
        }
    
        var deviceId = await deviceInstallationService.getDeviceId();
    
        if (deviceId.isEmpty) {
          throw "Unable to resolve an ID for the device.";
        }
    
        var response = await http
            .delete("$installationsUrl/$deviceId", headers: {"apikey": apikey});
    
        if (response.statusCode != 200) {
          throw "Deregister request failed: ${response.reasonPhrase}";
        }
    
        await secureStorage.delete(key: cachedDeviceTokenKey);
        await secureStorage.delete(key: cachedTagsKey);
      }
    
      Future<void> registerDevice(List<String> tags) async {
        try {
          final deviceId = await deviceInstallationService.getDeviceId();
          final platform = await deviceInstallationService.getDevicePlatform();
          final token = await deviceInstallationService.getDeviceToken();
    
          final deviceInstallation =
              DeviceInstallation(deviceId, platform, token, tags);
    
          final response = await http.put(installationsUrl,
              body: jsonEncode(deviceInstallation),
              headers: {"apikey": apikey, "Content-Type": "application/json"});
    
          if (response.statusCode != 200) {
            throw "Register request failed: ${response.reasonPhrase}";
          }
    
          final serializedTags = jsonEncode(tags);
    
          await secureStorage.write(key: cachedDeviceTokenKey, value: token);
          await secureStorage.write(key: cachedTagsKey, value: serializedTags);
        } on PlatformException catch (e) {
          throw e.message;
        } catch (e) {
          throw "Unable to register device: $e";
        }
      }
    
      Future<void> refreshRegistration() async {
        final currentToken = await deviceInstallationService.getDeviceToken();
        final cachedToken = await secureStorage.read(key: cachedDeviceTokenKey);
        final serializedTags = await secureStorage.read(key: cachedTagsKey);
    
        if (currentToken == null ||
            cachedToken == null ||
            serializedTags == null ||
            currentToken == cachedToken) {
          return;
        }
    
        final tags = jsonDecode(serializedTags);
    
        return registerDevice(tags);
      }
    
      Future<void> handleNotificationRegistrationCall(MethodCall call) async {
        switch (call.method) {
          case refreshRegistrationChannelMethod:
            return refreshRegistration();
          default:
            throw MissingPluginException();
            break;
        }
      }
    }
    

    Note

    This class encapsulates the use of the DeviceInstallationService and the requests to the backend service to perform the requisite register, deregister, and refresh registration actions. The apiKey argument is only required if you chose to complete the Authenticate clients using an API Key section.

  7. Add a new file to the lib folder called config.dart with the following implementation.

    class Config {
      static String apiKey = "API_KEY";
      static String backendServiceEndpoint = "BACKEND_SERVICE_ENDPOINT";
    }
    

    Note

    This is used as a simple way to define app secrets. Replace the placeholder values with your own. You should have made a note of these when you built the backend service. The API App URL should be https://<api_app_name>.azurewebsites.net/. The apiKey member is only required if you chose to complete the Authenticate clients using an API Key section.

    Be sure to add this to your gitignore file to avoid committing these secrets to source control.

Implement the cross-platform UI

  1. In main_page.dart, replace the build function with the following.

    @override
    Widget build(BuildContext context) {
    return Scaffold(
        body: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 40.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.end,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              FlatButton(
                child: Text("Register"),
                onPressed: registerButtonClicked,
              ),
              FlatButton(
                child: Text("Deregister"),
                onPressed: deregisterButtonClicked,
              ),
            ],
          ),
        ),
      );
    }
    
  2. Add the requisite imports to the top of the main_page.dart file.

    import 'package:push_demo/services/notification_registration_service.dart';
    import 'config.dart';
    
  3. Add a field to the _MainPageState class to store a reference to the NotificationRegistrationService.

    final notificationRegistrationService = NotificationRegistrationService(Config.backendServiceEndpoint, Config.apiKey);
    
  4. At the _MainPageState class, implement the event handlers for the Register and Deregister buttons onPressed events. Call the corresponding Register/Deregister methods then show an alert to indicate the result.

    void registerButtonClicked() async {
        try {
          await notificationRegistrationService.registerDevice(List<String>());
          await showAlert(message: "Device registered");
        }
        catch (e) {
          await showAlert(message: e);
        }
      }
    
      void deregisterButtonClicked() async {
        try {
          await notificationRegistrationService.deregisterDevice();
          await showAlert(message: "Device deregistered");
        }
        catch (e) {
          await showAlert(message: e);
        }
      }
    
      Future<void> showAlert({ message: String }) async {
        return showDialog<void>(
          context: context,
          barrierDismissible: false,
          builder: (BuildContext context) {
            return AlertDialog(
              title: Text('PushDemo'),
              content: SingleChildScrollView(
                child: ListBody(
                  children: <Widget>[
                    Text(message),
                  ],
                ),
              ),
              actions: <Widget>[
                FlatButton(
                  child: Text('OK'),
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                ),
              ],
            );
          },
        );
      }
    
  5. Now in main.dart, ensure the following imports are present at the top of the file.

    import 'package:flutter/material.dart';
    import 'package:push_demo/models/push_demo_action.dart';
    import 'package:push_demo/services/notification_action_service.dart';
    import 'package:push_demo/main_page.dart';
    
  6. Declare a variable to store reference to an instance of NotificationActionService and initialize it.

    final notificationActionService = NotificationActionService();
    
  7. Add functions to handle the display of an alert when an action is triggered.

    void notificationActionTriggered(PushDemoAction action) {
      showActionAlert(message: "${action.toString().split(".")[1]} action received");
    }
    
    Future<void> showActionAlert({ message: String }) async {
      return showDialog<void>(
        context: navigatorKey.currentState.overlay.context,
        barrierDismissible: false,
        builder: (BuildContext context) {
          return AlertDialog(
            title: Text('PushDemo'),
            content: SingleChildScrollView(
              child: ListBody(
                children: <Widget>[
                  Text(message),
                ],
              ),
            ),
            actions: <Widget>[
              FlatButton(
                child: Text('OK'),
                onPressed: () {
                  Navigator.of(context).pop();
                },
              ),
            ],
          );
        },
      );
    }
    
  8. Update the main function to observe the NotificationActionService actionTriggered stream and check for any actions captured during app launch.

    void main() async {
      runApp(MaterialApp(home: MainPage(), navigatorKey: navigatorKey,));
      notificationActionService.actionTriggered.listen((event) { notificationActionTriggered(event as PushDemoAction); });
      await notificationActionService.checkLaunchAction();
    }
    

    Note

    This is simply to demonstrate the receipt and propagation of push notification actions. Typically, these would be handled silently for example navigating to a specific view or refreshing some data rather than displaying an alert in this case.

Configure the native Android project for push notifications

Add the Google Services JSON file

  1. Control + Click on the android folder, then choose Open in Android Studio. Then, switch to the Project view (if it's not already).

  2. Locate the google-services.json file you downloaded earlier when you set up the PushDemo project in the Firebase Console. Then, drag it into the app module root directory (android > android > app).

Configure build settings and permissions

  1. Switch the Project view to Android.

  2. Open AndroidManifest.xml, then add the INTERNET and READ_PHONE_STATE permissions after the application element before the closing tag.

    <manifest>
        <application>...</application>
        <uses-permission android:name="android.permission.INTERNET" />
        <uses-permission android:name="android.permission.READ_PHONE_STATE" />
    </manifest>
    

Add the Firebase SDKs

  1. In Android Studio, open the project-level build.gradle file (Gradle Scripts > build.gradle (Project: android)). and ensure you have the `com.google.gms:google-services' classpath in the buildscript > dependencies node.

    buildscript {
    
      repositories {
        // Check that you have the following line (if not, add it):
        google()  // Google's Maven repository
      }
    
      dependencies {
        // ...
    
        // Add the following line:
        classpath 'com.google.gms:google-services:4.3.3'  // Google Services plugin
      }
    }
    
    allprojects {
      // ...
    
      repositories {
        // Check that you have the following line (if not, add it):
        google()  // Google's Maven repository
        // ...
      }
    }
    

    Note

    Ensure you reference the latest version as per the instructions provided in the Firebase Console when you created the Android Project.

  2. In the app-level build.gradle file (Gradle Scripts > build.gradle (Module: app)), apply the Google Services Gradle plugin. Apply the plugin right above the android node.

    // ...
    
    // Add the following line:
    apply plugin: 'com.google.gms.google-services'  // Google Services plugin
    
    android {
      // ...
    }
    
  3. In the same file, in the dependencies node, add the dependency for the Cloud Messaging Android library.

    dependencies {
        // ...
        implementation 'com.google.firebase:firebase-messaging:20.2.0'
    }
    

    Note

    Ensure you reference the latest version as per the Cloud Messaging Android client documentation.

  4. Save the changes, then click on the Sync Now button (from the toolbar prompt) or Sync Project with Gradle Files.

Handle push notifications for Android

  1. In Android Studio, Control + Click on the com.<your_organization>.pushdemo package folder (app > src > main > kotlin), choose Package from the New menu. Enter services as the name, then press Return.

  2. Control + Click on the services folder, choose Kotlin File/Class from the New menu. Enter DeviceInstallationService as the name, then press Return.

  3. Implement the DeviceInstallationService using the following code.

    package com.<your_organization>.pushdemo.services
    
    import android.annotation.SuppressLint
    import android.content.Context
    import android.provider.Settings.Secure
    import com.google.android.gms.common.ConnectionResult
    import com.google.android.gms.common.GoogleApiAvailability
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.plugin.common.MethodCall
    import io.flutter.plugin.common.MethodChannel
    
    @SuppressLint("HardwareIds")
    class DeviceInstallationService {
    
        companion object {
            const val DEVICE_INSTALLATION_CHANNEL = "com.<your_organization>.pushdemo/deviceinstallation"
            const val GET_DEVICE_ID = "getDeviceId"
            const val GET_DEVICE_TOKEN = "getDeviceToken"
            const val GET_DEVICE_PLATFORM = "getDevicePlatform"
        }
    
        private var context: Context
        private var deviceInstallationChannel : MethodChannel
    
        val playServicesAvailable
            get() = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS
    
        constructor(context: Context, flutterEngine: FlutterEngine) {
            this.context = context
            deviceInstallationChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_INSTALLATION_CHANNEL)
            deviceInstallationChannel.setMethodCallHandler { call, result -> handleDeviceInstallationCall(call, result) }
        }
    
        fun getDeviceId() : String
            = Secure.getString(context.applicationContext.contentResolver, Secure.ANDROID_ID)
    
        fun getDeviceToken() : String {
            if(!playServicesAvailable) {
                throw Exception(getPlayServicesError())
            }
    
            // TODO: Revisit once we have created the PushNotificationsFirebaseMessagingService
            val token = "Placeholder_Get_Value_From_FirebaseMessagingService_Implementation"
    
            if (token.isNullOrBlank()) {
                throw Exception("Unable to resolve token for FCM.")
            }
    
            return token
        }
    
        fun getDevicePlatform() : String = "fcm"
    
        private fun handleDeviceInstallationCall(call: MethodCall, result: MethodChannel.Result) {
            when (call.method) {
                GET_DEVICE_ID -> {
                    result.success(getDeviceId())
                }
                GET_DEVICE_TOKEN -> {
                    getDeviceToken(result)
                }
                GET_DEVICE_PLATFORM -> {
                    result.success(getDevicePlatform())
                }
                else -> {
                    result.notImplemented()
                }
            }
        }
    
        private fun getDeviceToken(result: MethodChannel.Result) {
            try {
                val token = getDeviceToken()
                result.success(token)
            }
            catch (e: Exception) {
                result.error("ERROR", e.message, e)
            }
        }
    
        private fun getPlayServicesError(): String {
            val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
    
            if (resultCode != ConnectionResult.SUCCESS) {
                return if (GoogleApiAvailability.getInstance().isUserResolvableError(resultCode)){
                    GoogleApiAvailability.getInstance().getErrorString(resultCode)
                } else {
                    "This device is not supported"
                }
            }
    
            return "An error occurred preventing the use of push notifications"
        }
    }
    

    Note

    This class implements the platform-specific counterpart for the com.<your_organization>.pushdemo/deviceinstallation channel. This was defined in the Flutter portion of the app within DeviceInstallationService.dart. In this case, the calls are made from the common code to the native host. Be sure to replace <your_organization> with your own organization wherever this is used.

    This class provides a unique ID (using Secure.AndroidId) as part of the notification hub registration payload.

  4. Add another Kotlin File/Class to the services folder called NotificationRegistrationService, then add the following code.

    package com.<your_organization>.pushdemo.services
    
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.plugin.common.MethodChannel
    
    class NotificationRegistrationService {
    
        companion object {
            const val NOTIFICATION_REGISTRATION_CHANNEL = "com.<your_organization>.pushdemo/notificationregistration"
            const val REFRESH_REGISTRATION = "refreshRegistration"
        }
    
        private var notificationRegistrationChannel : MethodChannel
    
        constructor(flutterEngine: FlutterEngine) {
            notificationRegistrationChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, NotificationRegistrationService.NOTIFICATION_REGISTRATION_CHANNEL)
        }
    
        fun refreshRegistration() {
            notificationRegistrationChannel.invokeMethod(REFRESH_REGISTRATION, null)
        }
    }
    

    Note

    This class implements the platform-specific counterpart for the com.<your_organization>.pushdemo/notificationregistration channel. This was defined in the Flutter portion of the app within NotificationRegistrationService.dart. In this case, the calls are made from the native host to the common code. Again, take care to replace <your_organization> with your own organization wherever this is used.

  5. Add another Kotlin File/Class to the services folder called NotificationActionService, then add the following code.

    package com.<your_organization>.pushdemo.services
    
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.plugin.common.MethodCall
    import io.flutter.plugin.common.MethodChannel
    
    class NotificationActionService {
        companion object {
            const val NOTIFICATION_ACTION_CHANNEL = "com.<your_organization>.pushdemo/notificationaction"
            const val TRIGGER_ACTION = "triggerAction"
            const val GET_LAUNCH_ACTION = "getLaunchAction"
        }
    
        private var notificationActionChannel : MethodChannel
        var launchAction : String? = null
    
        constructor(flutterEngine: FlutterEngine) {
            notificationActionChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, NotificationActionService.NOTIFICATION_ACTION_CHANNEL)
            notificationActionChannel.setMethodCallHandler { call, result -> handleNotificationActionCall(call, result) }
        }
    
        fun triggerAction(action: String) {
            notificationActionChannel.invokeMethod(NotificationActionService.TRIGGER_ACTION, action)
        }
    
        private fun handleNotificationActionCall(call: MethodCall, result: MethodChannel.Result) {
            when (call.method) {
                NotificationActionService.GET_LAUNCH_ACTION -> {
                    result.success(launchAction)
                }
                else -> {
                    result.notImplemented()
                }
            }
        }
    }
    

    Note

    This class implements the platform-specific counterpart for the com.<your_organization>.pushdemo/notificationaction channel. That was defined in the Flutter portion of the app within NotificationActionService.dart. Calls can be made in both directions in this case. Be sure to replace <your_organization> with your own organization wherever this is used.

  6. Add a new Kotlin File/Class to the com.<your_organization>.pushdemo package called PushNotificationsFirebaseMessagingService, then implement using the following code.

    package com.<your_organization>.pushdemo
    
    import android.os.Handler
    import android.os.Looper
    import com.google.firebase.messaging.FirebaseMessagingService
    import com.google.firebase.messaging.RemoteMessage
    import com.<your_organization>.pushdemo.services.NotificationActionService
    import com.<your_organization>.pushdemo.services.NotificationRegistrationService
    
    class PushNotificationsFirebaseMessagingService : FirebaseMessagingService() {
    
        companion object {
            var token : String? = null
            var notificationRegistrationService : NotificationRegistrationService? = null
            var notificationActionService : NotificationActionService? = null
        }
    
        override fun onNewToken(token: String) {
            PushNotificationsFirebaseMessagingService.token = token
            notificationRegistrationService?.refreshRegistration()
        }
    
        override fun onMessageReceived(message: RemoteMessage) {
            message.data.let {
                Handler(Looper.getMainLooper()).post {
                    notificationActionService?.triggerAction(it.getOrDefault("action", null))
                }
            }
        }
    }
    

    Note

    This class is responsible for handling notifications when the app is running in the foreground. It will conditionally call the triggerAction on the NotificationActionService if an action is included in the notification payload that is received in onMessageReceived. This will also call refreshRegistration on the NotificationRegistrationService when the Firebase token gets regenerated by overriding the onNewToken function.

    Once again, take care to replace <your_organization> with your own organization wherever it is used.

  7. In AndroidManifest.xml (app > src > main), add the PushNotificationsFirebaseMessagingService to the bottom of the application element with the com.google.firebase.MESSAGING_EVENT intent filter.

    <manifest>
        <application>
            <!-- EXISTING MANIFEST CONTENT -->
             <service
                android:name="com.<your_organization>.pushdemo.PushNotificationsFirebaseMessagingService"
                android:exported="false">
                <intent-filter>
                    <action android:name="com.google.firebase.MESSAGING_EVENT" />
                </intent-filter>
            </service>
        </application>
    </manifest>
    
  8. Back in DeviceInstallationService, ensure the following imports are present at the top of the file.

    package com.<your_organization>.pushdemo
    import com.<your_organization>.pushdemo.services.PushNotificationsFirebaseMessagingService
    

    Note

    Replace <your_organization> with your own organization value.

  9. Update the placeholder text Placeholder_Get_Value_From_FirebaseMessagingService_Implementation to get the token value from the PushNotificationFirebaseMessagingService.

    fun getDeviceToken() : String {
        if(!playServicesAvailable) {
            throw Exception(getPlayServicesError())
        }
    
        // Get token from the PushNotificationsFirebaseMessagingService.token field.
        val token = PushNotificationsFirebaseMessagingService.token
    
        if (token.isNullOrBlank()) {
            throw Exception("Unable to resolve token for FCM.")
        }
    
        return token
    }
    
  10. In MainActivity, ensure the following imports are present at the top of the file.

    package com.<your_organization>.pushdemo
    
    import android.content.Intent
    import android.os.Bundle
    import com.google.android.gms.tasks.OnCompleteListener
    import com.google.firebase.iid.FirebaseInstanceId
    import com.<your_organization>.pushdemo.services.DeviceInstallationService
    import com.<your_organization>.pushdemo.services.NotificationActionService
    import com.<your_organization>.pushdemo.services.NotificationRegistrationService
    import io.flutter.embedding.android.FlutterActivity
    

    Note

    Replace <your_organization> with your own organization value.

  11. Add a variable to store a reference to the DeviceInstallationService.

    private lateinit var deviceInstallationService: DeviceInstallationService
    
  12. Add a function called processNotificationActions to check whether an Intent has an extra value named action. Conditionally trigger that action or store it for use later if the action is being processed during app launch.

     private fun processNotificationActions(intent: Intent, launchAction: Boolean = false) {
        if (intent.hasExtra("action")) {
            var action = intent.getStringExtra("action");
    
            if (action.isNotEmpty()) {
                if (launchAction) {
                    PushNotificationsFirebaseMessagingService.notificationActionService?.launchAction = action
                }
                else {
                    PushNotificationsFirebaseMessagingService.notificationActionService?.triggerAction(action)
                }
            }
        }
    }
    
  13. Override the onNewIntent function to call processNotificationActions.

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        processNotificationActions(intent)
    }
    

    Note

    Since the LaunchMode for MainActivity is set to SingleTop, an Intent will be sent to the existing Activity instance via the onNewIntent function rather than the onCreate function and so you must handle an incoming Intent in both onCreate and onNewIntent functions.

  14. Override the onCreate function, set the deviceInstallationService to a new instance of DeviceInstallationService.

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    
        flutterEngine?.let {
            deviceInstallationService = DeviceInstallationService(context, it)
        }
    }
    
  15. Set the notificationActionService and notificationRegistrationService properties on PushNotificationFirebaseMessagingServices.

    flutterEngine?.let {
      deviceInstallationService = DeviceInstallationService(context, it)
      PushNotificationsFirebaseMessagingService.notificationActionService = NotificationActionService(it)
      PushNotificationsFirebaseMessagingService.notificationRegistrationService = NotificationRegistrationService(it)
    }
    
  16. In the same function, conditionally call FirebaseInstanceId.getInstance().instanceId. Implement the OnCompleteListener to set the resulting token value on PushNotificationFirebaseMessagingService before calling refreshRegistration.

    if(deviceInstallationService?.playServicesAvailable) {
        FirebaseInstanceId.getInstance().instanceId
            .addOnCompleteListener(OnCompleteListener { task ->
                if (!task.isSuccessful)
                    return@OnCompleteListener
    
                PushNotificationsFirebaseMessagingService.token = task.result?.token
                PushNotificationsFirebaseMessagingService.notificationRegistrationService?.refreshRegistration()
            })
    }
    
  17. Still in onCreate, call processNotificationActions at the end of the function. Use true for the launchAction argument to indicate this action is being processed during app launch.

    processNotificationActions(this.intent, true)
    

Note

You must re-register the app each time you run it and stop it from a debug session to continue receiving push notifications.

Configure the native iOS project for push notifications

Configure the runner target and Info.plist

  1. In Visual Studio Code, Control + Click on the ios folder, then choose Open in Xcode.

  2. In Xcode, click on Runner (the xcodeproj at the top, not the folder) then select the Runner target, and then Signing & Capabilities. With the All build configuration selected, choose your Developer account for the Team. Ensure the "Automatically manage signing" option is checked and your Signing Certificate and Provisioning Profile are automatically selected.

    Note

    If you don't see the new Provisioning Profile value, try refreshing the profiles for the Signing Identity by selecting Xcode > Preferences > Account then select the Download Manual Profiles button to download the profiles.

  3. Click on + Capability, then search for Push Notifications. Double-Click on Push Notifications to add this capability.

  4. Open Info.plist and set Minimum system version to 13.0.

    Note

    Only those devices running iOS 13.0 and above are supported for the purposes of this tutorial however you can extend it to support devices running older versions.

  5. Open Runner.entitlements and ensure the APS Environment setting is set to development.

Handle push notifications for iOS

  1. Control + Click on the Runner folder (within the Runner project), then choose New Group using Services as the name.

  2. Control + Click on the Services folder, then choose New File.... Then, choose Swift File and click Next. Specify DeviceInstallationService for the name and then click Create.

  3. Implement DeviceInstallationService.swift using the following code.

    import Foundation
    
    class DeviceInstallationService {
    
        enum DeviceRegistrationError: Error {
            case notificationSupport(message: String)
        }
    
        var token : Data? = nil
    
        let DEVICE_INSTALLATION_CHANNEL = "com.<your_organization>.pushdemo/deviceinstallation"
        let GET_DEVICE_ID = "getDeviceId"
        let GET_DEVICE_TOKEN = "getDeviceToken"
        let GET_DEVICE_PLATFORM = "getDevicePlatform"
    
        private let deviceInstallationChannel : FlutterMethodChannel
    
        var notificationsSupported : Bool {
            get {
                if #available(iOS 13.0, *) {
                    return true
                }
                else {
                    return false
                }
            }
        }
    
        init(withBinaryMessenger binaryMessenger : FlutterBinaryMessenger) {
            deviceInstallationChannel = FlutterMethodChannel(name: DEVICE_INSTALLATION_CHANNEL, binaryMessenger: binaryMessenger)
            deviceInstallationChannel.setMethodCallHandler(handleDeviceInstallationCall)
        }
    
        func getDeviceId() -> String {
            return UIDevice.current.identifierForVendor!.description
        }
    
        func getDeviceToken() throws -> String {
            if(!notificationsSupported) {
                let notificationSupportError = getNotificationsSupportError()
                throw DeviceRegistrationError.notificationSupport(message: notificationSupportError)
            }
    
            if (token == nil) {
                throw DeviceRegistrationError.notificationSupport(message: "Unable to resolve token for APNS.")
            }
    
            return token!.reduce("", {$0 + String(format: "%02X", $1)})
        }
    
        func getDevicePlatform() -> String {
            return "apns"
        }
    
        private func handleDeviceInstallationCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
            switch call.method {
            case GET_DEVICE_ID:
                result(getDeviceId())
            case GET_DEVICE_TOKEN:
                getDeviceToken(result: result)
            case GET_DEVICE_PLATFORM:
                result(getDevicePlatform())
            default:
                result(FlutterMethodNotImplemented)
            }
        }
    
        private func getDeviceToken(result: @escaping FlutterResult) {
            do {
                let token = try getDeviceToken()
                result(token)
            }
            catch let error {
                result(FlutterError(code: "UNAVAILABLE", message: error.localizedDescription, details: nil))
            }
        }
    
        private func getNotificationsSupportError() -> String {
    
            if (!notificationsSupported) {
                return "This app only supports notifications on iOS 13.0 and above. You are running \(UIDevice.current.systemVersion)"
            }
    
            return "An error occurred preventing the use of push notifications."
        }
    }
    

    Note

    This class implements the platform-specific counterpart for the com.<your_organization>.pushdemo/deviceinstallation channel. This was defined in the Flutter portion of the app within DeviceInstallationService.dart. In this case, the calls are made from the common code to the native host. Be sure to replace <your_organization> with your own organization wherever this is used.

    This class provides a unique ID (using the UIDevice.identifierForVendor value) as part of the notification hub registration payload.

  4. Add another Swift File to the Services folder called NotificationRegistrationService, then add the following code.

    import Foundation
    
    class NotificationRegistrationService {
    
        let NOTIFICATION_REGISTRATION_CHANNEL = "com.<your_organization>.pushdemo/notificationregistration"
        let REFRESH_REGISTRATION = "refreshRegistration"
    
        private let notificationRegistrationChannel : FlutterMethodChannel
    
        init(withBinaryMessenger binaryMessenger : FlutterBinaryMessenger) {
           notificationRegistrationChannel = FlutterMethodChannel(name: NOTIFICATION_REGISTRATION_CHANNEL, binaryMessenger: binaryMessenger)
        }
    
        func refreshRegistration() {
            notificationRegistrationChannel.invokeMethod(REFRESH_REGISTRATION, arguments: nil)
        }
    }
    

    Note

    This class implements the platform-specific counterpart for the com.<your_organization>.pushdemo/notificationregistration channel. This was defined in the Flutter portion of the app within NotificationRegistrationService.dart. In this case, the calls are made from the native host to the common code. Again, take care to replace <your_organization> with your own organization wherever this is used.

  5. Add another Swift File to the Services folder called NotificationActionService, then add the following code.

    import Foundation
    
    class NotificationActionService {
    
        let NOTIFICATION_ACTION_CHANNEL = "com.<your_organization>.pushdemo/notificationaction"
        let TRIGGER_ACTION = "triggerAction"
        let GET_LAUNCH_ACTION = "getLaunchAction"
    
        private let notificationActionChannel: FlutterMethodChannel
    
        var launchAction: String? = nil
    
        init(withBinaryMessenger binaryMessenger: FlutterBinaryMessenger) {
            notificationActionChannel = FlutterMethodChannel(name: NOTIFICATION_ACTION_CHANNEL, binaryMessenger: binaryMessenger)
            notificationActionChannel.setMethodCallHandler(handleNotificationActionCall)
        }
    
        func triggerAction(action: String) {
           notificationActionChannel.invokeMethod(TRIGGER_ACTION, arguments: action)
        }
    
        private func handleNotificationActionCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
            switch call.method {
            case GET_LAUNCH_ACTION:
                result(launchAction)
            default:
                result(FlutterMethodNotImplemented)
            }
        }
    }
    

    Note

    This class implements the platform-specific counterpart for the com.<your_organization>.pushdemo/notificationaction channel. That was defined in the Flutter portion of the app within NotificationActionService.dart. Calls can be made in both directions in this case. Be sure to replace <your_organization> with your own organization wherever this is used.

  6. In AppDelegate.swift, add variables to store a reference to the services you created previously.

    var deviceInstallationService : DeviceInstallationService?
    var notificationRegistrationService : NotificationRegistrationService?
    var notificationActionService : NotificationActionService?
    
  7. Add a function called processNotificationActions for processing the notification data. Conditionally trigger that action or store it for use later if the action is being processed during app launch.

    func processNotificationActions(userInfo: [AnyHashable : Any], launchAction: Bool = false) {
        if let action = userInfo["action"] as? String {
            if (launchAction) {
                notificationActionService?.launchAction = action
            }
            else {
                notificationActionService?.triggerAction(action: action)
            }
        }
    }
    
  8. Override the didRegisterForRemoteNotificationsWithDeviceToken function setting the token value for the DeviceInstallationService. Then, call refreshRegistration on the NotificationRegistrationService.

    override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
      deviceInstallationService?.token = deviceToken
      notificationRegistrationService?.refreshRegistration()
    }
    
  9. Override the didReceiveRemoteNotification function passing the userInfo argument to the processNotificationActions function.

    override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) {
        processNotificationActions(userInfo: userInfo)
    }
    
  10. Override the didFailToRegisterForRemoteNotificationsWithError function to log the error.

    override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print(error);
    }
    

    Note

    This is very much a placeholder. You will want to implement proper logging and error handling for production scenarios.

  11. In didFinishLaunchingWithOptions, instantiate the deviceInstallationService, notificationRegistrationService, and notificationActionService variables.

    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    
    deviceInstallationService = DeviceInstallationService(withBinaryMessenger: controller.binaryMessenger)
    notificationRegistrationService = NotificationRegistrationService(withBinaryMessenger: controller.binaryMessenger)
    notificationActionService = NotificationActionService(withBinaryMessenger: controller.binaryMessenger)
    
  12. In the same function, conditionally request authorization and register for remote notifications.

    if #available(iOS 13.0, *) {
      UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) {
          (granted, error) in
    
          if (granted)
          {
              DispatchQueue.main.async {
                  let pushSettings = UIUserNotificationSettings(types: [.alert, .sound, .badge], categories: nil)
                  application.registerUserNotificationSettings(pushSettings)
                  application.registerForRemoteNotifications()
              }
          }
      }
    }
    
  13. If the launchOptions contains the remoteNotification key, call processNotificationActions at the end of the didFinishLaunchingWithOptions function. Pass in the resulting userInfo object and use true for the launchAction argument. A true value denotes that the action is being processed during app launch.

    if let userInfo = launchOptions?[.remoteNotification] as? [AnyHashable : Any] {
        processNotificationActions(userInfo: userInfo, launchAction: true)
    }
    

Test the solution

You can now test sending notifications via the backend service.

Send a test notification

  1. Open a new tab in Postman.

  2. Set the request to POST, and enter the following address:

    https://<app_name>.azurewebsites.net/api/notifications/requests
    
  3. If you chose to complete the Authenticate clients using an API Key section, be sure to configure the request headers to include your apikey value.

    Key Value
    apikey <your_api_key>
  4. Choose the raw option for the Body, then choose JSON from the list of format options, and then include some placeholder JSON content:

    {
        "text": "Message from Postman!",
        "action": "action_a"
    }
    
  5. Select the Code button, which is under the Save button on the upper right of the window. The request should look similar to the following example when displayed for HTML (depending on whether you included an apikey header):

    POST /api/notifications/requests HTTP/1.1
    Host: https://<app_name>.azurewebsites.net
    apikey: <your_api_key>
    Content-Type: application/json
    
    {
        "text": "Message from backend service",
        "action": "action_a"
    }
    
  6. Run the PushDemo application on one or both of the target platforms (Android and iOS).

    Note

    If you are testing on Android ensure that you are not running in Debug, or if the app has been deployed by running the application then force close the app and start it again from the launcher.

  7. In the PushDemo app, tap on the Register button.

  8. Back in Postman, close the Generate Code Snippets window (if you haven't done so already) then click the Send button.

  9. Validate that you get a 200 OK response in Postman and the alert appears in the app showing ActionA action received.

  10. Close the PushDemo app, then click the Send button again in Postman.

  11. Validate that you get a 200 OK response in Postman again. Validate that a notification appears in the notification area for the PushDemo app with the correct message.

  12. Tap on the notification to confirm that it opens the app and displayed the ActionA action received alert.

  13. Back in Postman, modify the previous request body to send a silent notification specifying action_b instead of action_a for the action value.

    {
        "action": "action_b",
        "silent": true
    }
    
  14. With the app still open, click the Send button in Postman.

  15. Validate that you get a 200 OK response in Postman and that the alert appears in the app showing ActionB action received instead of ActionA action received.

  16. Close the PushDemo app, then click the Send button again in Postman.

  17. Validate that you get a 200 OK response in Postman and that the silent notification doesn't appear in the notification area.

Troubleshooting

No response from the backend service

When testing locally, ensure that the backend service is running and is using the correct port.

If testing against the Azure API App, check the service is running and has been deployed and has started without error.

Be sure to check you've specified the base address correctly in Postman or in the mobile app configuration when testing via the client. The base address should indicatively be https://<api_name>.azurewebsites.net/ or https://localhost:5001/ when testing locally.

Not receiving notifications on Android after starting or stopping a debug session

Ensure you register again after starting or stopping a debug session. The debugger will cause a new Firebase token to be generated. The notification hub installation must be updated as well.

Receiving a 401 status code from the backend service

Validate that you're setting the apikey request header and this value matches the one you had configured for the backend service.

If you receive this error when testing locally, ensure the key value you defined in the client config, matches the Authentication:ApiKey user-setting value used by the API.

If you're testing with an API App, ensure the key value in the client config file matches the Authentication:ApiKey application setting you're using in the API App.

Note

If you had created or changed this setting after you had deployed the backend service then you must restart the service in order for it take effect.

If you chose not to complete the Authenticate clients using an API Key section, ensure that you didn't apply the Authorize attribute to the NotificationsController class.

Receiving a 404 status code from the backend service

Validate that the endpoint and HTTP request method is correct. For example, the endpoints should indicatively be:

  • [PUT] https://<api_name>.azurewebsites.net/api/notifications/installations
  • [DELETE] https://<api_name>.azurewebsites.net/api/notifications/installations/<installation_id>
  • [POST] https://<api_name>.azurewebsites.net/api/notifications/requests

Or when testing locally:

  • [PUT] https://localhost:5001/api/notifications/installations
  • [DELETE] https://localhost:5001/api/notifications/installations/<installation_id>
  • [POST] https://localhost:5001/api/notifications/requests

When specifying the base address in the client app, ensure it ends with a /. The base address should indicatively be https://<api_name>.azurewebsites.net/ or https://localhost:5001/ when testing locally.

Unable to register and a notification hub error message is displayed

Verify that the test device has network connectivity. Then, determine the Http response status code by setting a breakpoint to inspect the StatusCode property value in the HttpResponse.

Review the previous troubleshooting suggestions where applicable based on the status code.

Set a breakpoint on the lines that return these specific status codes for the respective API. Then try calling the backend service when debugging locally.

Validate the backend service is working as expected via Postman using the appropriate payload. Use the actual payload created by the client code for the platform in question.

Review the platform-specific configuration sections to ensure that no steps have been missed. Check that suitable values are being resolved for installation id and token variables for the appropriate platform.

Unable to resolve an ID for the device error message is displayed

Review the platform-specific configuration sections to ensure that no steps have been missed.

Next steps

You should now have a basic Flutter app connected to a notification hub via a backend service and can send and receive notifications.

You'll likely need to adapt the example used in this tutorial to fit your own scenario. Implementing more robust error handling, retry logic, and logging is also recommended.

Visual Studio App Center can be quickly incorporated into mobile apps providing analytics and diagnostics to aid in troubleshooting.