maui-push-notifications
SKILL.md
Push Notifications for .NET MAUI
Architecture
MAUI app → ASP.NET Core backend → Azure Notification Hub → FCM (Android) / APNS (iOS) → device.
Step 1 — Create Azure Notification Hub
- Azure Portal → create a Notification Hub inside a Notification Hub Namespace.
- Apple (APNS) → upload
.p8key or.p12cert; set mode to Sandbox for dev. - Google (FCM V1) → paste the FCM V1 service-account JSON from Firebase Console → Project Settings → Cloud Messaging.
- Copy
DefaultFullSharedAccessSignatureand hub name for backendappsettings.json.
Step 2 — ASP.NET Core backend API
2.1 NuGet package
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.*" />
2.2 appsettings.json
{
"NotificationHub": {
"Name": "<hub-name>",
"ConnectionString": "Endpoint=sb://...;SharedAccessKeyName=DefaultFullSharedAccessSignature;SharedAccessKey=..."
},
"Authentication": { "ApiKey": "<random-guid-or-secret>" }
}
2.3 Models
namespace YOUR_NAMESPACE.Backend.Models;
public class DeviceInstallation
{
public string InstallationId { get; set; } = "";
public string Platform { get; set; } = ""; // "fcmv1" or "apns"
public string PushChannel { get; set; } = ""; // device token
public List<string> Tags { get; set; } = [];
}
public class NotificationRequest
{
public string Title { get; set; } = "";
public string Body { get; set; } = "";
public List<string> Tags { get; set; } = [];
}
2.4 NotificationHubService
namespace YOUR_NAMESPACE.Backend.Services;
using Microsoft.Azure.NotificationHubs;
using Microsoft.Extensions.Options;
public class NotificationHubOptions
{
public string Name { get; set; } = "";
public string ConnectionString { get; set; } = "";
}
public interface INotificationService
{
Task<bool> CreateOrUpdateInstallationAsync(DeviceInstallation device, CancellationToken ct);
Task<bool> DeleteInstallationByIdAsync(string installationId, CancellationToken ct);
Task<bool> RequestNotificationAsync(NotificationRequest request, CancellationToken ct);
}
public class NotificationHubService : INotificationService
{
readonly NotificationHubClient _hub;
public NotificationHubService(IOptions<NotificationHubOptions> options)
{
_hub = NotificationHubClient.CreateClientFromConnectionString(
options.Value.ConnectionString, options.Value.Name);
}
public async Task<bool> CreateOrUpdateInstallationAsync(DeviceInstallation device, CancellationToken ct)
{
var installation = new Installation
{
InstallationId = device.InstallationId,
PushChannel = device.PushChannel,
Tags = device.Tags,
Platform = device.Platform switch
{
"fcmv1" => NotificationPlatform.FcmV1,
"apns" => NotificationPlatform.Apns,
_ => throw new ArgumentException($"Unknown platform: {device.Platform}")
}
};
await _hub.CreateOrUpdateInstallationAsync(installation, ct);
return true;
}
public async Task<bool> DeleteInstallationByIdAsync(string installationId, CancellationToken ct)
{
await _hub.DeleteInstallationAsync(installationId, ct);
return true;
}
public async Task<bool> RequestNotificationAsync(NotificationRequest request, CancellationToken ct)
{
// GOTCHA: Azure NH limits tag expressions to 20 tags — batch accordingly.
var batches = request.Tags.Chunk(20);
foreach (var batch in batches)
{
var tagExpr = string.Join(" || ", batch);
var fcm = $$"""{"message":{"notification":{"title":"{{request.Title}}","body":"{{request.Body}}"}}}""";
var apns = $$"""{"aps":{"alert":{"title":"{{request.Title}}","body":"{{request.Body}}"}}}""";
await Task.WhenAll(
_hub.SendFcmV1NativeNotificationAsync(fcm, tagExpr, ct),
_hub.SendAppleNativeNotificationAsync(apns, tagExpr, ct));
}
return true;
}
}
2.5 Minimal API endpoints (Program.cs)
builder.Services.Configure<NotificationHubOptions>(
builder.Configuration.GetSection("NotificationHub"));
builder.Services.AddSingleton<INotificationService, NotificationHubService>();
var app = builder.Build();
var apiKey = builder.Configuration["Authentication:ApiKey"]!;
var api = app.MapGroup("/api/notifications")
.AddEndpointFilter(async (ctx, next) =>
{
if (!ctx.HttpContext.Request.Headers.TryGetValue("apikey", out var key) || key != apiKey)
return Results.Unauthorized();
return await next(ctx);
});
api.MapPut("/installations", async (DeviceInstallation device,
INotificationService svc, CancellationToken ct) =>
await svc.CreateOrUpdateInstallationAsync(device, ct) ? Results.Ok() : Results.BadRequest());
api.MapDelete("/installations/{id}", async (string id,
INotificationService svc, CancellationToken ct) =>
await svc.DeleteInstallationByIdAsync(id, ct) ? Results.Ok() : Results.BadRequest());
api.MapPost("/requests", async (NotificationRequest req,
INotificationService svc, CancellationToken ct) =>
await svc.RequestNotificationAsync(req, ct) ? Results.Ok() : Results.BadRequest());
app.Run();
Step 3 — MAUI client shared code
3.1 Config
namespace YOUR_NAMESPACE;
public static class PushConfig
{
// GOTCHA: trailing slash required — HttpClient.BaseAddress must end with "/".
public const string BackendServiceEndpoint = "https://<your-backend>.azurewebsites.net/";
public const string ApiKey = "<same-key-as-backend>";
}
3.2 IPushNotificationService
namespace YOUR_NAMESPACE.Services;
public interface IPushNotificationService
{
string Token { get; set; }
Task RegisterAsync(CancellationToken ct = default);
Task DeregisterAsync(CancellationToken ct = default);
}
3.3 PushNotificationService
namespace YOUR_NAMESPACE.Services;
using System.Net.Http.Json;
using System.Text.Json;
public class PushNotificationService : IPushNotificationService
{
static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
readonly HttpClient _http;
public string Token { get; set; } = "";
public PushNotificationService()
{
_http = new HttpClient { BaseAddress = new Uri(PushConfig.BackendServiceEndpoint) };
_http.DefaultRequestHeaders.Add("apikey", PushConfig.ApiKey);
}
public async Task RegisterAsync(CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(Token)) return;
var installation = new
{
installationId = GetInstallationId(),
platform = DeviceInfo.Platform == DevicePlatform.Android ? "fcmv1" : "apns",
pushChannel = Token,
tags = new[] { $"user:{GetUserId()}" }
};
await _http.PutAsJsonAsync("api/notifications/installations", installation, _json, ct);
}
public async Task DeregisterAsync(CancellationToken ct = default)
{
await _http.DeleteAsync($"api/notifications/installations/{GetInstallationId()}", ct);
}
string GetInstallationId()
{
var id = Preferences.Get("installation_id", string.Empty);
if (string.IsNullOrEmpty(id))
{
id = Guid.NewGuid().ToString();
Preferences.Set("installation_id", id);
}
return id;
}
string GetUserId() => "default-user"; // Replace with your auth identity.
}
3.4 DI registration (MauiProgram.cs)
builder.Services.AddSingleton<IPushNotificationService, PushNotificationService>();
Step 4 — Android setup
4.1 Firebase project
- Firebase Console → Add Android app with your package name.
- Download
google-services.json→ place in Platforms/Android/. - In
.csproj:<GoogleServicesJson Include="Platforms\Android\google-services.json" />
4.2 NuGet packages (Android)
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0-android'">
<PackageReference Include="Xamarin.Firebase.Messaging" Version="124.*" />
<PackageReference Include="Xamarin.Google.Dagger" Version="2.*" />
</ItemGroup>
4.3 AndroidManifest.xml additions
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service android:name=".PushNotificationFirebaseMessagingService" android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default_channel" />
</application>
4.4 MainActivity.cs
namespace YOUR_NAMESPACE;
using Android;
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.Gms.Tasks;
using Android.OS;
using Firebase.Messaging;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation
| ConfigChanges.UiMode | ConfigChanges.ScreenLayout
| ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity, IOnSuccessListener
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
CreateNotificationChannel();
if (Build.VERSION.SdkInt >= BuildVersionCodes.Tiramisu)
RequestPermissions(new[] { Manifest.Permission.PostNotifications }, 0);
FirebaseMessaging.Instance.GetToken().AddOnSuccessListener(this);
}
public void OnSuccess(Java.Lang.Object result)
{
var svc = IPlatformApplication.Current!.Services.GetRequiredService<IPushNotificationService>();
svc.Token = result.ToString()!;
_ = svc.RegisterAsync();
}
void CreateNotificationChannel()
{
if (Build.VERSION.SdkInt < BuildVersionCodes.O) return;
var channel = new NotificationChannel("default_channel", "General", NotificationImportance.Default);
((NotificationManager)GetSystemService(NotificationService)!).CreateNotificationChannel(channel);
}
}
4.5 PushNotificationFirebaseMessagingService.cs
namespace YOUR_NAMESPACE.Platforms.Android;
using global::Android.App;
using Firebase.Messaging;
[Service(Exported = false)]
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
public class PushNotificationFirebaseMessagingService : FirebaseMessagingService
{
public override void OnNewToken(string token)
{
// GOTCHA: tokens regenerate frequently during debug builds — always re-register.
var svc = IPlatformApplication.Current?.Services.GetService<IPushNotificationService>();
if (svc is null) return;
svc.Token = token;
_ = svc.RegisterAsync();
}
public override void OnMessageReceived(RemoteMessage message)
{
var n = message.GetNotification();
if (n is null) return;
var intent = new global::Android.Content.Intent(this, typeof(MainActivity));
intent.AddFlags(global::Android.Content.ActivityFlags.ClearTop);
var pending = PendingIntent.GetActivity(this, 0, intent,
PendingIntentFlags.OneShot | PendingIntentFlags.Immutable);
var builder = new Notification.Builder(this, "default_channel")
.SetContentTitle(n.Title ?? "")
.SetContentText(n.Body ?? "")
.SetSmallIcon(Resource.Drawable.appiconfg)
.SetAutoCancel(true)
.SetContentIntent(pending);
((NotificationManager)GetSystemService(NotificationService)!).Notify(0, builder.Build());
}
}
Step 5 — iOS setup
5.1 Apple Developer portal & Entitlements
- Enable Push Notifications capability for your App ID.
- Create an APNs Key (
.p8) and upload to Azure Notification Hub. - Add
Platforms/iOS/Entitlements.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
- In
.csproj:
<PropertyGroup Condition="$(TargetFramework.Contains('-ios'))">
<CodesignEntitlements>Platforms\iOS\Entitlements.plist</CodesignEntitlements>
</PropertyGroup>
5.2 AppDelegate.cs
namespace YOUR_NAMESPACE.Platforms.iOS;
using Foundation;
using UIKit;
using UserNotifications;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions)
{
var result = base.FinishedLaunching(application, launchOptions);
UNUserNotificationCenter.Current.RequestAuthorization(
UNAuthorizationOptions.Alert | UNAuthorizationOptions.Badge | UNAuthorizationOptions.Sound,
(granted, _) =>
{
if (granted)
InvokeOnMainThread(UIApplication.SharedApplication.RegisterForRemoteNotifications);
});
return result;
}
[Export("application:didRegisterForRemoteNotificationsWithDeviceToken:")]
public void RegisteredForRemoteNotifications(UIApplication application, NSData deviceToken)
{
var token = BitConverter.ToString(deviceToken.ToArray()).Replace("-", "").ToLowerInvariant();
var svc = IPlatformApplication.Current?.Services.GetService<IPushNotificationService>();
if (svc is null) return;
svc.Token = token;
_ = svc.RegisterAsync();
}
[Export("application:didReceiveRemoteNotification:fetchCompletionHandler:")]
public void ReceivedRemoteNotification(UIApplication application,
NSDictionary userInfo, Action<UIBackgroundFetchResult> completionHandler)
{
completionHandler(UIBackgroundFetchResult.NewData);
}
}
Step 6 — Test the pipeline
- Run the backend locally or deploy to Azure App Service.
- Android: deploy to device or emulator with Google Play Services.
- iOS: deploy to a physical device — simulators cannot receive APNS push notifications.
- Send a test notification:
curl -X POST https://<your-backend>/api/notifications/requests \
-H "Content-Type: application/json" \
-H "apikey: <your-api-key>" \
-d '{"title":"Hello","body":"Push works!","tags":["user:default-user"]}'
Gotchas and troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Token changes every debug run (Android) | Debug builds regenerate Firebase tokens | Re-register on every OnNewToken — handled above |
HttpClient requests fail silently |
BaseAddress missing trailing / |
Ensure endpoint ends with / |
| iOS push won't arrive on simulator | Simulators don't support APNS | Use a physical iOS device |
| No notifications on Android 13+ | POST_NOTIFICATIONS permission required (API 33+) |
Call RequestPermissions in OnCreate |
| Notification channel missing (API 26+) | Android requires explicit channel creation | Create channel before sending |
SendNotificationAsync throws for >20 tags |
Azure NH tag expression limit | Batch tags in groups of 20 |
422 on registration |
Platform string mismatch | Use "fcmv1" (not "gcm") and "apns" |
Token empty at RegisterAsync |
Race condition on cold start | Guard with IsNullOrWhiteSpace check |
Weekly Installs
6
Repository
davidortinau/maui-skillsGitHub Stars
71
First Seen
Mar 1, 2026
Security Audits
Installed on
opencode6
github-copilot6
codex6
kimi-cli6
gemini-cli6
amp6