Push-ilmoitukset .NET MAUI -sovelluksessa vaativat sekä Firebase FCM:n Androidille että APNs:n iOS:lle. Tässä käytännön oppaassa käydään läpi HTTP v1 -rajapinta, token-rekisteröinti, notification channels, deep linking Shell-reitille ja tuotannon kompastuskivet.
Push-ilmoitukset .NET MAUI -sovelluksessa vaativat aina kaksi rinnakkaista integraatiota: Firebase Cloud Messaging (FCM) Androidilla ja Apple Push Notification service (APNs) iOS:llä. Vaikka useimmat oppaat esittävät FCM:n "yhtenä API:na", todellisuudessa iOS-viestit reititetään aina APNs:n läpi ja Android-viestit natiivin FCM SDK:n läpi, eikä MAUI muuta tätä totuutta. Tämä opas käy läpi käyttöoikeuksien pyytämisen, token-rekisteröinnin, notification channels -kanavat, HTTP v1 -rajapinnan sekä yleisimmät kompastuskivet .NET 9/10 -tuotannossa.
Legacy-FCM lopetettiin 20.6.2024, joten tuotannossa on käytettävä HTTP v1 -rajapintaa OAuth 2.0 -tokenilla.
iOS vaatii APNs .p8 -avaimen (ei sertifikaattia) Firebase-konsoliin sekä Push Notifications -entitlementin.
Android 13+ (API 33) vaatii POST_NOTIFICATIONS-runtime-luvan ja notification channelin ennen ilmoituksen näyttämistä.
Plugin.FirebasePushNotifications on kypsin cross-platform-NuGet-paketti MAUI:lle; Azure Notification Hubs sopii yrityksen mittakaavaan.
Data-only-payload antaa täyden hallinnan foreground-, background- ja terminated-tiloissa. Pelkkä notification-payload näytetään OS:n toimesta ilman koodikutsua.
Deep linkkaus MAUI Shell -reitille onnistuu välittämällä route data-payloadissa ja käsittelemällä se OnNotificationOpened-tapahtumassa.
Miksi push MAUI:ssa on hankalaa
Rehellisesti sanottuna: kirjoitan tämän oppaan sen vuoksi, että olen nähnyt liian monta MAUI-tiimiä juuttuvan viikoksi siihen, mikä pitäisi olla iltapäivän työ. Ongelma ei ole MAUI itsessään, vaan se, että push-ilmoitukset ovat aidosti kaksi eri natiivia järjestelmää (Firebase Cloud Messaging Androidilla ja Apple UserNotifications-framework iOS:llä), jotka MAUI on kääritty saman rajapinnan taakse. Kun jokin menee pieleen, virheilmoitus tulee yleensä natiivista tasosta ja tuntuu ensilukemalta täysin käsittämättömältä.
Toinen syy vaikeuteen on ekosysteemin sekavuus. Microsoftin virallinen dokumentaatio ohjaa Azure Notification Hubsiin, joka toimii kyllä hyvin, mutta lisää välikerroksen ja kustannuksen. Yhteisö taas käyttää enimmäkseen Plugin.FirebasePushNotifications-pakettia, joka on kevyempi mutta jonka päivityssykli riippuu yhdestä ylläpitäjästä. Kolmas vaihtoehto on Shiny.Push, jolla on hyvä lifecycle-tuki mutta jyrkempi oppimiskäyrä. Kaikilla on paikkansa, ja valinta vaikuttaa suoraan siihen, miltä seuraavien lukujen koodi näyttää sinun projektissasi.
Kolmas kompastuskivi on ajoitus. Google lopetti Legacy-FCM-rajapinnan 20.6.2024. Jos joku ohje verkossa käskee POST-pyyntöä osoitteeseen fcm.googleapis.com/fcm/send otsikolla Authorization: key=..., se on jo vanhentunut. Tuotannossa on käytettävä pelkästään HTTP v1 -rajapintaa, joka autentikoituu OAuth 2.0 -tokenilla eli käytännössä palvelutilin avaimella, jolta tuotettu access token elää tunnin.
Firebase-projektin luominen ja HTTP v1 -rajapinta
Aloita luomalla Firebase-projekti osoitteessa console.firebase.google.com. Lisää projektille sekä Android- että iOS-sovellus, koska sama Firebase-projekti hallitsee molempien alustojen tokeneita. Androidille annat sovelluksen paketti-ID:n (esimerkiksi com.tuoyritys.appi) ja lataat syntyneen google-services.json-tiedoston. iOS:lle annat Bundle Identifierin ja lataat GoogleService-Info.plist-tiedoston.
Seuraava kriittinen askel on palvelutilin luonti backend-lähetyksiä varten. Avaa Firebase-konsolista Project settings → Service accounts, klikkaa Generate new private key ja tallenna JSON-tiedosto turvalliseen paikkaan. Tätä JSON-avainta EI saa laittaa mobiilisovellukseen. Se kuuluu ainoastaan palvelinpuolelle, jossa se luo lyhytikäisiä access tokeneita FCM HTTP v1 -kutsuja varten. Firebase migraatio-ohjeen mukaan tokeneilla on tunnin elinaika ja ne on uusittava tämän välein.
Android-integraatio: google-services.json ja channels
Kopioi ladattu google-services.json polkuun Platforms/Android/ ja aseta sen Build Action -asetukseksi GoogleServicesJson. Ilman tätä Xamarin.Google.Services-pluginin generaattori ei tuota google-services.xml-resursseja, eikä FCM SDK löydä projektin määrittelyjä ajonaikaisesti. Hiljainen virhe, muuten. (Törmäsin tähän itse viimeksi ihan hiljattain.)
Android 13:sta (API 33) alkaen on pyydettävä käyttäjältä runtime-lupa POST_NOTIFICATIONS ennen kuin järjestelmä sallii ilmoitusten näyttämisen. Lisää lupa manifestiin ja pyydä se ajonaikaisesti. MAUI:ssa tämä hoituu Permissions-API:n läpi, mutta POST_NOTIFICATIONS ei ole vielä vakiona Essentialsissa, joten se on toteutettava itse:
// Platforms/Android/PostNotificationsPermission.cs
using AndroidX.Core.App;
using AndroidX.Core.Content;
using Android;
using Android.Content.PM;
public static class PostNotificationsPermission
{
public static bool IsGranted()
{
if (OperatingSystem.IsAndroidVersionAtLeast(33) == false)
return true; // Alle 33 lupa ei ole vaadittu
var context = Android.App.Application.Context;
return ContextCompat.CheckSelfPermission(context, Manifest.Permission.PostNotifications)
== Permission.Granted;
}
public static void Request(Android.App.Activity activity)
{
if (OperatingSystem.IsAndroidVersionAtLeast(33))
{
ActivityCompat.RequestPermissions(activity,
new[] { Manifest.Permission.PostNotifications }, requestCode: 1001);
}
}
}
Notification channels (kanavat) tulivat pakollisiksi Android 8.0:ssa (API 26). Jokainen ilmoitus, jonka näytät, on liitettävä kanavaan, tai järjestelmä hylkää sen hiljaisesti. Käytännössä kannattaa luoda kanavat MainActivityn OnCreatessa, eikä vasta ensimmäisen ilmoituksen tullessa, koska taustavastaanotto voi tapahtua ennen kuin Activity ehtii herätä:
// Platforms/Android/MainActivity.cs
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
CreateNotificationChannels();
}
private void CreateNotificationChannels()
{
if (OperatingSystem.IsAndroidVersionAtLeast(26) == false) return;
var manager = (NotificationManager)GetSystemService(NotificationService)!;
var general = new NotificationChannel(
"general",
"Yleiset ilmoitukset",
NotificationImportance.Default)
{
Description = "Vahvistukset ja päivitykset"
};
var alerts = new NotificationChannel(
"alerts",
"Kiireelliset hälytykset",
NotificationImportance.High)
{
Description = "Tilaus- ja turvahälytykset"
};
alerts.EnableVibration(true);
manager.CreateNotificationChannel(general);
manager.CreateNotificationChannel(alerts);
}
iOS-integraatio: APNs .p8 ja entitlements
iOS-puolella on erotettava kaksi asiaa: APNs-avain, jolla Firebase saa oikeuden lähettää viestejä Applen kautta, sekä sovelluksen entitlement, jolla iOS itse sallii ilmoitusten vastaanoton. Molemmat on hoidettava, ja kummankin unohtaminen johtaa täysin erilaiseen virheeseen.
Luo Applen Developer-portaalissa uusi Key kohdassa Certificates, Identifiers & Profiles → Keys → +, valitse Apple Push Notifications service (APNs) ja lataa syntyneen avaimen .p8-tiedosto. Muista talteen myös Key ID ja Team ID. Palaa Firebase-konsoliin, avaa iOS-sovelluksen asetukset kohdasta Project settings → Cloud Messaging ja lataa .p8-tiedosto sekä syötä ID:t. Vanhempi vaihtoehto (.p12-sertifikaatti) toimii yhä, mutta Apple suosittelee .p8-avaimia, koska ne eivät vanhene ja toimivat sekä sandbox- että tuotantoympäristöissä.
Sovellukseen tarvitaan entitlement-tiedosto. Luo Platforms/iOS/Entitlements.plist:
Arvo development viittaa APNs-sandboxiin, joka toimii Xcode-buildeilla ja TestFlight-esibuildeilla. Tuotannossa (App Store) arvo on production. .csproj:issa entitlement-tiedosto on kytkettävä oikeaan konfiguraatioon:
Info.plist-tiedostoon on lisättävä UIBackgroundModes-avain arvolla remote-notification, jotta silent-push voi herättää sovelluksen taustatyötä varten. Ilman tätä data-only-viestit menevät perille vain sovelluksen ollessa foregroundissa. Olen menettänyt tähän itse muutaman iltapäivän.
Plugin.FirebasePushNotifications käytössä
Plugin.FirebasePushNotifications abstrahoi FCM:n ja APNs:n saman C#-rajapinnan taakse. Se kääriitä sisäänsä natiivit SDK:t (Firebase Android ja Firebase iOS) ja tarjoaa MAUI-idiomaattisen tapahtumamallin. Asenna NuGetilla:
Sovelluksen alkuun (esimerkiksi App.xaml.cs:n konstruktoriin) lisätään tapahtumakäsittelijät. Tässä on koko elinkaaren kannalta tärkein pala:
using Plugin.FirebasePushNotifications;
public partial class App : Application
{
public App()
{
InitializeComponent();
CrossFirebasePushNotification.Current.OnTokenRefresh += (s, e) =>
Task.Run(() => TokenService.SendToBackendAsync(e.Token));
CrossFirebasePushNotification.Current.OnNotificationReceived += (s, e) =>
{
// Kutsutaan kun sovellus on foregroundissa
Debug.WriteLine($"Ilmoitus vastaanotettu: {JsonSerializer.Serialize(e.Data)}");
};
CrossFirebasePushNotification.Current.OnNotificationOpened += (s, e) =>
{
// Kutsutaan kun käyttäjä koskee ilmoitusta
if (e.Data.TryGetValue("route", out var route))
MainThread.BeginInvokeOnMainThread(() =>
Shell.Current.GoToAsync($"//{route}"));
};
}
}
Token-rekisteröinti backendiin
Jokainen laite saa FCM:ltä yksilöllisen token-merkkijonon, jota käytetään lähetysosoitteena. Token voi vaihtua monista syistä: sovellus asennetaan uudelleen, käyttäjä tyhjentää sovellusdatan, Firebase itse rotatoi tokenin. Tästä syystä on rakennettava luotettava synkronointimekanismi backendiin. Aiheesta on hyvä lukea myös oppaani REST API .NET MAUI -sovelluksessa: HttpClient, DI ja MVVM käytännössä, jossa on käyty läpi HTTP-integraation perusrakenne.
Yksinkertaisin toteutus tallentaa tokenin ja lähettää sen palvelimelle vasta kun se on muuttunut. Tallennus onnistuu turvallisesti SecureStorage-API:lla:
public class TokenService
{
private const string StorageKey = "fcm_token";
private readonly HttpClient _http;
public TokenService(HttpClient http) => _http = http;
public async Task SendToBackendAsync(string token)
{
var previous = await SecureStorage.GetAsync(StorageKey);
if (previous == token) return;
var payload = new
{
token,
platform = DeviceInfo.Platform.ToString().ToLowerInvariant(),
appVersion = AppInfo.VersionString
};
var response = await _http.PostAsJsonAsync("/api/devices/register", payload);
response.EnsureSuccessStatusCode();
await SecureStorage.SetAsync(StorageKey, token);
}
}
Viestin lähetys HTTP v1 -rajapinnalla
Palvelinpuolella viesti lähetetään HTTP POST -pyyntönä osoitteeseen https://fcm.googleapis.com/v1/projects/PROJEKTI_ID/messages:send. Access token generoidaan palvelutilin avaimesta (esimerkiksi Google.Apis.Auth-kirjaston avulla .NET-backendissä). Tässä minimalinen esimerkki ASP.NET Core -backendistä, joka lähettää viestin yhdelle laitteelle:
using Google.Apis.Auth.OAuth2;
using System.Net.Http.Json;
public class FcmSender
{
private readonly HttpClient _http;
private readonly GoogleCredential _credential;
private readonly string _projectId;
public FcmSender(HttpClient http, string serviceAccountJsonPath, string projectId)
{
_http = http;
_projectId = projectId;
_credential = GoogleCredential
.FromFile(serviceAccountJsonPath)
.CreateScoped("https://www.googleapis.com/auth/firebase.messaging");
}
public async Task SendAsync(string deviceToken, string title, string body,
IDictionary<string, string>? data = null)
{
var accessToken = await _credential.UnderlyingCredential
.GetAccessTokenForRequestAsync();
var payload = new
{
message = new
{
token = deviceToken,
notification = new { title, body },
data,
android = new { priority = "high" },
apns = new
{
payload = new { aps = new { sound = "default" } }
}
}
};
var url = $"https://fcm.googleapis.com/v1/projects/{_projectId}/messages:send";
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload)
};
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
var response = await _http.SendAsync(request);
response.EnsureSuccessStatusCode();
}
}
Huomaa payloadin rakenne. Notification-lohko määrittelee OS:n automaattisesti näyttämän ilmoituksen, ja data-lohko on aina saatavilla sovelluksen omalle koodille. Jos haluat, että sovellus päättää itse mitä käyttäjälle näytetään kaikissa tiloissa, jätä notification-lohko pois ja lähetä pelkkä data-payload. Silloin OS ei renderöi mitään ja koodisi saa täyden hallinnan.
Foreground, background ja terminated -tilat
Push-koodin ymmärtäminen edellyttää sovelluksen kolmen tilan tuntemisen. Foreground tarkoittaa, että käyttäjä katsoo sovellusta parhaillaan. Molemmilla alustoilla ilmoitusta EI näytetä automaattisesti, vaan sovelluksen on itse päätettävä mitä tehdä. Tyypillisesti näytetään in-app-toast tai päivitetään näkymä hiljaisesti.
Background tarkoittaa, että sovellus on käynnissä mutta ei näkyvissä. Androidilla saapunut viesti näytetään ilmoituksena, mikäli payloadissa on notification-lohko; jos vain data, sovelluksen FirebaseMessagingService herää käsittelemään sen. iOS:llä normaali push näytetään OS:n toimesta, mutta silent-push (content-available: 1 apsissa ilman ääntä tai title/body-kenttiä) käynnistää didReceiveRemoteNotification-tapahtuman ja antaa noin 30 sekuntia työskentelyaikaa.
Terminated on tila, jossa käyttäjä on sulkenut sovelluksen swipe-eleellä (iOS) tai järjestelmä on lopettanut prosessin (Android). Silloin normaali push näytetään edelleen, mutta silent-push ei enää iOS:llä käynnistä sovellusta. Applen dokumentaatio on tästä eksplisiittinen. Androidilla FirebaseMessagingService voi vielä herätä, mutta jos käyttäjä on tappanut sovelluksen "Force Stop" -toiminnolla, mikään ei tule läpi ennen seuraavaa käynnistystä.
Deep linking ilmoituksesta MAUI Shell -reitille
Suurin osa push-ilmoituksista johtaa käyttäjän tiettyyn sovelluksen näkymään: tilaukseen, viestiketjuun, tuotteeseen. MAUI:ssa tämä toteutetaan lähettämällä Shell-reitti data-payloadissa ja käsittelemällä se OnNotificationOpened-tapahtumassa. Jos Shell-navigointi ei ole tuttua, kannattaa aloittaa oppaani .NET MAUI Shell -navigointi: opas välilehtiin, flyout-valikkoon ja deep linkingiin läpikäynnistä.
Testaa aluksi Firebase-konsolin Cloud Messaging → Send test message -toiminnolla, johon voit liittää yhden tokenin. Tämä varmistaa, että FCM-projekti, google-services.json ja APNs-avain toimivat yhdessä. Kun tämä läpäisee, siirry backendin HTTP v1 -kutsuihin ja testaa notification-, data- ja combined-payloadit erikseen. (Kolme erillistä testiä, ei yksi.)
iOS:n APNs-sandbox ja -tuotanto ovat erillisiä ympäristöjä. Xcode-buildit ja Firebase-konsolin testiviestit menevät sandboxiin, kun taas TestFlight ja App Store käyttävät tuotantoympäristöä. Sama .p8-avain toimii molempiin, mutta entitlement-tiedoston aps-environment on vaihdettava build-konfiguraation mukaan. Yleinen kompastuskivi on unohtaa vaihtaa arvo Release-buildissa, jolloin sovellus toimii TestFlightissä muttei App Storessa.
Android-tuotannon suurin ongelma on OEM-valmistajien aggressiivinen akunhallinta. Xiaomi, Huawei, Oppo ja Samsung tappavat sovelluksia usein akkusäästösyistä, jolloin FirebaseMessagingService ei enää käynnisty. Käyttäjän on lisättävä sovellus manuaalisesti akkusäästön poikkeuksiin. Tätä ei voi ohjelmallisesti kiertää, joten kannattaa dokumentoida ohjeet sovelluksen ohjeosioon suosituille laitteille.
Tuotantoon vietäessä varmista vielä:
Firebase-konsolissa on ladattu production-tason APNs-avain ja oikea Bundle Identifier.
Palvelutilin JSON-avain on backendin secret storessa, ei Git-repossa.
POST_NOTIFICATIONS-lupaa pyydetään käyttäjän ymmärtämässä kontekstissa (ei heti sovelluksen avautuessa).
Backendissä on retry-logiikka 5xx-vastauksille ja token-poisto 404 UNREGISTERED -vastauksille.
Analytiikka seuraa ilmoituksen open rate -metriikkaa, muuten et koskaan tiedä toimiiko integraatio.
Usein kysyttyjä kysymyksiä
Tukeeko .NET MAUI push-ilmoituksia natiivisti?
MAUI ei sisällä sisäänrakennettua push-toteutusta, mutta se kääri natiivit Android- ja iOS-SDK:t niin, että voit käyttää Plugin.FirebasePushNotifications-, Shiny.Push- tai Azure Notification Hubs -kirjastoja. Kaikki toimivat MAUI:n platform-invocation-mallilla ja tuottavat saman C#-rajapinnan Android- ja iOS-koodille.
Toimiiko vanha FCM Legacy API vielä vuonna 2026?
Ei. Google poisti Legacy HTTP- ja XMPP-rajapinnat 20.6.2024. Kaikkien tuotantosovellusten on käytettävä FCM HTTP v1 -rajapintaa, joka autentikoituu OAuth 2.0 -tokenilla palvelutilin avaimesta. Vanhat Authorization: key=... -kutsut palauttavat nyt 404-virheen.
Voiko iOS:lle lähettää push-viestejä ilman Firebasea?
Kyllä. APNs:n voi kutsua suoraan HTTP/2-yhteyden yli tokenilla tai .p12-sertifikaatilla, jolloin Firebasea ei tarvita lainkaan. Käytännössä useimmat MAUI-projektit valitsevat kuitenkin FCM:n, koska sen läpi voi lähettää saman payloadin sekä Androidiin että iOS:ään yhtenä pyyntönä.
Miksi ilmoitus näkyy Androidissa mutta ei iOS:ssä?
Yleisin syy on APNs-avaimen puute Firebase-konsolissa tai puuttuva Push Notifications -entitlement iOS-projektissa. Toinen yleinen ongelma on virheellinen aps-environment: arvo development ei toimi TestFlight- eikä App Store -buildeissa, jotka vaativat production.
Miten ilmoituksesta navigoidaan tiettyyn näkymään MAUI:ssa?
Lisää backendin lähettämään payloadin data-lohkoon Shell-reittitieto (esim. "route": "orders/details") ja käsittele OnNotificationOpened-tapahtumassa Shell.Current.GoToAsync-kutsulla. Parametrit voi lähettää mukana ja ottaa vastaan kohde-ViewModelissa [QueryProperty]-attribuutilla.