From 966491305745219e933efe456ccfd18bf51fa6a9 Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Sat, 30 Sep 2023 07:30:19 +0300 Subject: [PATCH] Imported Tingle.Extensions.PushNotifications --- README.md | 1 + Tingle.Extensions.sln | 14 ++ .../Apple/ApnsAuthenticationHandler.cs | 79 ++++++ .../Apple/ApnsNotifier.cs | 128 ++++++++++ .../Apple/ApnsNotifierConfigureOptions.cs | 36 +++ .../Apple/ApnsNotifierOptions.cs | 32 +++ .../Apple/ApnsNotifierOptionsExtensions.cs | 76 ++++++ .../Apple/Models/ApnsAlert.cs | 88 +++++++ .../Apple/Models/ApnsEnvironment.cs | 17 ++ .../Apple/Models/ApnsErrorReason.cs | 158 ++++++++++++ .../Apple/Models/ApnsMessageData.cs | 16 ++ .../Apple/Models/ApnsMessageHeader.cs | 57 +++++ .../Apple/Models/ApnsMessagePayload.cs | 70 ++++++ .../Apple/Models/ApnsMessageResponse.cs | 14 ++ .../Apple/Models/ApnsPriority.cs | 23 ++ .../Apple/Models/ApnsPushType.cs | 73 ++++++ .../Apple/Models/ApnsResponseError.cs | 29 +++ .../FcmLegacyAuthenticationHandler.cs | 25 ++ .../FcmLegacy/FcmLegacyNotifier.cs | 36 +++ .../FcmLegacyNotifierConfigureOptions.cs | 18 ++ .../FcmLegacy/FcmLegacyNotifierOptions.cs | 15 ++ .../FcmLegacy/Models/FcmLegacyErrorCode.cs | 122 ++++++++++ .../FcmLegacy/Models/FcmLegacyNotification.cs | 22 ++ .../Models/FcmLegacyNotificationAndroid.cs | 98 ++++++++ .../Models/FcmLegacyNotificationIos.cs | 95 ++++++++ .../Models/FcmLegacyNotificationWeb.cs | 22 ++ .../FcmLegacy/Models/FcmLegacyPriority.cs | 22 ++ .../FcmLegacy/Models/FcmLegacyRequest.cs | 162 +++++++++++++ .../FcmLegacy/Models/FcmLegacyResponse.cs | 52 ++++ .../FcmLegacy/Models/FcmLegacyResult.cs | 24 ++ .../Firebase/FirebaseAuthenticationHandler.cs | 90 +++++++ .../Firebase/FirebaseNotifier.cs | 36 +++ .../FirebaseNotifierConfigureOptions.cs | 36 +++ .../Firebase/FirebaseNotifierOptions.cs | 30 +++ .../FirebaseNotifierOptionsExtensions.cs | 96 ++++++++ .../Firebase/Models/FirebaseErrorCode.cs | 46 ++++ .../Firebase/Models/FirebaseMessageAndroid.cs | 70 ++++++ .../Models/FirebaseMessageAndroidColor.cs | 12 + .../FirebaseMessageAndroidFcmOptions.cs | 13 + .../FirebaseMessageAndroidLightSettings.cs | 16 ++ .../FirebaseMessageAndroidNotification.cs | 227 ++++++++++++++++++ ...ebaseMessageAndroidNotificationPriority.cs | 44 ++++ .../Models/FirebaseMessageAndroidPriority.cs | 26 ++ .../FirebaseMessageAndroidVisibility.cs | 28 +++ .../Firebase/Models/FirebaseMessageApns.cs | 32 +++ .../Models/FirebaseMessageApnsFcmOptions.cs | 20 ++ .../Models/FirebaseMessageFcmOptions.cs | 13 + .../Firebase/Models/FirebaseMessageWebpush.cs | 37 +++ .../FirebaseMessageWebpushFcmOptions.cs | 19 ++ .../Firebase/Models/FirebaseNotification.cs | 30 +++ .../Firebase/Models/FirebaseRequest.cs | 32 +++ .../Firebase/Models/FirebaseRequestMessage.cs | 63 +++++ .../Firebase/Models/FirebaseResponse.cs | 18 ++ .../Models/FirebaseResponseProblem.cs | 55 +++++ .../IServiceCollectionExtensions.cs | 93 +++++++ .../PushNotificationsJsonSerializerContext.cs | 21 ++ .../README.md | 164 +++++++++++++ .../ResourceResponseExtensions.cs | 20 ++ ...Tingle.Extensions.PushNotifications.csproj | 21 ++ .../ApnsNotifierTests.cs | 134 +++++++++++ .../DynamicHttpMessageHandler.cs | 21 ++ .../FcmLegacyNotifierTests.cs | 88 +++++++ .../FirebaseNotifierTests.cs | 165 +++++++++++++ ....Extensions.PushNotifications.Tests.csproj | 18 ++ 64 files changed, 3458 insertions(+) create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/ApnsAuthenticationHandler.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifier.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierConfigureOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptionsExtensions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsAlert.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsEnvironment.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsErrorReason.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageData.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageHeader.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessagePayload.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageResponse.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPriority.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPushType.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsResponseError.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyAuthenticationHandler.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifier.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierConfigureOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyErrorCode.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotification.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationAndroid.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationIos.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationWeb.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyPriority.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyRequest.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResponse.cs create mode 100644 src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResult.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/FirebaseAuthenticationHandler.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifier.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierConfigureOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptionsExtensions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseErrorCode.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroid.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidColor.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidFcmOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidLightSettings.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotification.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotificationPriority.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidPriority.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidVisibility.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApns.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApnsFcmOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageFcmOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpush.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpushFcmOptions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseNotification.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequest.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequestMessage.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponse.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponseProblem.cs create mode 100644 src/Tingle.Extensions.PushNotifications/IServiceCollectionExtensions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/PushNotificationsJsonSerializerContext.cs create mode 100644 src/Tingle.Extensions.PushNotifications/README.md create mode 100644 src/Tingle.Extensions.PushNotifications/ResourceResponseExtensions.cs create mode 100644 src/Tingle.Extensions.PushNotifications/Tingle.Extensions.PushNotifications.csproj create mode 100644 tests/Tingle.Extensions.PushNotifications.Tests/ApnsNotifierTests.cs create mode 100644 tests/Tingle.Extensions.PushNotifications.Tests/DynamicHttpMessageHandler.cs create mode 100644 tests/Tingle.Extensions.PushNotifications.Tests/FcmLegacyNotifierTests.cs create mode 100644 tests/Tingle.Extensions.PushNotifications.Tests/FirebaseNotifierTests.cs create mode 100644 tests/Tingle.Extensions.PushNotifications.Tests/Tingle.Extensions.PushNotifications.Tests.csproj diff --git a/README.md b/README.md index feecb6f..965829f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This repository contains projects/libraries for adding useful functionality to d |`Tingle.Extensions.JsonPatch`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.JsonPatch.svg)](https://www.nuget.org/packages/Tingle.Extensions.JsonPatch/)|JSON Patch (RFC 6902) support for .NET to easily generate JSON Patch documents using `System.Text.Json` for client applications. See [docs](./src/Tingle.Extensions.JsonPatch/README.md).| |`Tingle.Extensions.PhoneValidators`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.PhoneValidators.svg)](https://www.nuget.org/packages/Tingle.Extensions.PhoneValidators/)|Convenience for validation of phone numbers either via attributes or resolvable services. See [docs](./src/Tingle.Extensions.PhoneValidators/README.md).| |`Tingle.Extensions.Processing`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.Processing.svg)](https://www.nuget.org/packages/Tingle.Extensions.Processing/)|Helpers for making processing of bulk in memory tasks. See [docs](./src/Tingle.Extensions.Processing/README.md).| +|`Tingle.Extensions.PushNotifications`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.PushNotifications.svg)](https://www.nuget.org/packages/Tingle.Extensions.PushNotifications/)|Helpers for making processing of bulk in memory tasks. See [docs](./src/Tingle.Extensions.PushNotifications/README.md).| |`Tingle.Extensions.Serilog`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.Serilog.svg)](https://www.nuget.org/packages/Tingle.Extensions.Serilog/)|Extensions for working with [Serilog](https://serilog.net/). Including easier registration when working with different host setups, and general basics. See [docs](./src/Tingle.Extensions.Serilog/README.md) and [sample](./samples/SerilogSample).| ### Issues & Comments diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index 98a2475..ae5ac95 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -32,6 +32,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneVali EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing", "src\Tingle.Extensions.Processing\Tingle.Extensions.Processing.csproj", "{A803DE4B-B050-48F2-82A1-8E947D8FB96C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PushNotifications", "src\Tingle.Extensions.PushNotifications\Tingle.Extensions.PushNotifications.csproj", "{6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Serilog", "src\Tingle.Extensions.Serilog\Tingle.Extensions.Serilog.csproj", "{29035EF2-2391-4441-AAC5-85AA43586EEB}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{815F0941-3B70-4705-A583-AF627559595C}" @@ -63,6 +65,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneVali EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing.Tests", "tests\Tingle.Extensions.Processing.Tests\Tingle.Extensions.Processing.Tests.csproj", "{978023EA-2ED5-4A28-96AD-4BB914EF2BE5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PushNotifications.Tests", "tests\Tingle.Extensions.PushNotifications.Tests\Tingle.Extensions.PushNotifications.Tests.csproj", "{A8AB6597-DEBB-4828-AE1B-A11FD818E428}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Serilog.Tests", "tests\Tingle.Extensions.Serilog.Tests\Tingle.Extensions.Serilog.Tests.csproj", "{8E611861-09A3-4AE4-8392-E7CB9BE02B3B}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F}" @@ -138,6 +142,10 @@ Global {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.ActiveCfg = Release|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.Build.0 = Release|Any CPU + {6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Release|Any CPU.Build.0 = Release|Any CPU {29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -190,6 +198,10 @@ Global {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.Build.0 = Release|Any CPU + {A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Release|Any CPU.Build.0 = Release|Any CPU {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -239,6 +251,7 @@ Global {913C0212-58AC-42B7-B555-F96B8E287E7F} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {F46ADD04-B716-4E9B-9799-7C47DDDB08FC} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {A803DE4B-B050-48F2-82A1-8E947D8FB96C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {6A1901A5-D01F-47E2-9ED5-2BE4CCE95100} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {29035EF2-2391-4441-AAC5-85AA43586EEB} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {A324CC70-36DD-4B38-9EC8-9069F6130FAC} = {815F0941-3B70-4705-A583-AF627559595C} {E67CB6B9-6F42-4E63-9603-810B5B9FBF57} = {815F0941-3B70-4705-A583-AF627559595C} @@ -252,6 +265,7 @@ Global {B82E2980-E145-4341-BAE0-8FAE1F110D0C} = {815F0941-3B70-4705-A583-AF627559595C} {526B37AD-256A-445A-9A42-E5C53989B11E} = {815F0941-3B70-4705-A583-AF627559595C} {978023EA-2ED5-4A28-96AD-4BB914EF2BE5} = {815F0941-3B70-4705-A583-AF627559595C} + {A8AB6597-DEBB-4828-AE1B-A11FD818E428} = {815F0941-3B70-4705-A583-AF627559595C} {8E611861-09A3-4AE4-8392-E7CB9BE02B3B} = {815F0941-3B70-4705-A583-AF627559595C} {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {E04EC969-2539-46E9-B918-8C8B7BEB8828} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/ApnsAuthenticationHandler.cs b/src/Tingle.Extensions.PushNotifications/Apple/ApnsAuthenticationHandler.cs new file mode 100644 index 0000000..4baf740 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/ApnsAuthenticationHandler.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using Tingle.Extensions.Http.Authentication; + +namespace Tingle.Extensions.PushNotifications.Apple; + +/// +/// Implementation of for . +/// +internal class ApnsAuthenticationHandler : CachingAuthenticationHeaderHandler +{ + private readonly ApnsNotifierOptions options; + + public ApnsAuthenticationHandler(IMemoryCache cache, IOptionsSnapshot optionsAccessor, ILogger logger) + { + Scheme = "bearer"; + Cache = new(cache); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + options = optionsAccessor?.Value ?? throw new ArgumentNullException(nameof(optionsAccessor)); + } + + /// + public override string CacheKey => $"apns:tokens:{options.TeamId}:{options.KeyId}"; + + /// + protected override async Task GetParameterAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // get the token from cache + var token = await GetTokenFromCacheAsync(cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(token)) + { + var keyId = options.KeyId!; + + // prepare header + var header = new ApnsAuthHeader("ES256", keyId); + var header_json = System.Text.Json.JsonSerializer.Serialize(header, PushNotificationsJsonSerializerContext.Default.ApnsAuthHeader); + var headerBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(header_json)); + + // prepare payload + var payload = new ApnsAuthPayload(options.TeamId, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + var payload_json = System.Text.Json.JsonSerializer.Serialize(payload, PushNotificationsJsonSerializerContext.Default.ApnsAuthPayload); + var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload_json)); + + // import key, https://stackoverflow.com/a/44008229 + var privateKey = await options.PrivateKeyBytes!(keyId).ConfigureAwait(false); + using var ecdsa = ECDsa.Create(); + ecdsa.ImportPkcs8PrivateKey(privateKey, out _); + + // sign data + var unsignedJwtData = $"{headerBase64}.{payloadBase64}"; + var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(unsignedJwtData), HashAlgorithmName.SHA256); + token = $"{unsignedJwtData}.{Convert.ToBase64String(signature)}"; + + // according to apple docs, the token should be refreshed no more than once every 20 minutes and + // no less than once every 60 minutes. + // https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns + var expires = DateTimeOffset.UtcNow.AddMinutes(60); + + // save the token to the cache and set expiry in-line with the token life time + // bring the expiry time 5 seconds earlier to allow time for renewal + expires -= TimeSpan.FromSeconds(5); + await SetTokenInCacheAsync(token, expires, cancellationToken).ConfigureAwait(false); + } + + return token; + } + + internal record ApnsAuthHeader([property: JsonPropertyName("alg")] string? Algorithm, + [property: JsonPropertyName("kid")] string? KeyId); + internal record ApnsAuthPayload([property: JsonPropertyName("iss")] string? TeamId, + [property: JsonPropertyName("iat")] long IssuedAtSeconds); +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifier.cs b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifier.cs new file mode 100644 index 0000000..68126e5 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifier.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text.Json.Serialization.Metadata; +using Tingle.Extensions.Http; +using Tingle.Extensions.PushNotifications.Apple.Models; +using SC = Tingle.Extensions.PushNotifications.PushNotificationsJsonSerializerContext; + +namespace Tingle.Extensions.PushNotifications.Apple; + +/// +/// A push notifier for Apple's Push Notification Service +/// +public class ApnsNotifier : AbstractHttpApiClient +{ + internal const string ProductionBaseUrl = "https://api.push.apple.com:443"; + internal const string DevelopmentBaseUrl = "https://api.development.push.apple.com:443"; + + /// + /// Creates an instance of + /// + /// the client for making requests + /// the accessor for the configuration options + public ApnsNotifier(HttpClient httpClient, IOptionsSnapshot optionsAccessor) : base(httpClient, optionsAccessor) { } + + /// Send a push notification via Apple Push Notification Service (APNS). + /// The header for the notification + /// The data + /// + /// + public virtual Task> SendAsync(ApnsMessageHeader header, + ApnsMessageData data, + CancellationToken cancellationToken = default) + => SendAsync(header, data, SC.Default.ApnsMessageData, cancellationToken); + + /// Send a push notification with custom data via Apple Push Notification Service (APNS). + /// The header for the notification + /// The data + /// Metadata about the to convert. + /// + /// + public virtual async Task> SendAsync(ApnsMessageHeader header, + TData data, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default) + where TData : ApnsMessageData + { + // infer the endpoint from the provided environment + var endpoint = new Uri(header.Environment == ApnsEnvironment.Production ? ProductionBaseUrl : DevelopmentBaseUrl); + + // ensure we have a valid device token + if (string.IsNullOrWhiteSpace(header.DeviceToken)) + { + throw new ArgumentException($"{nameof(header.DeviceToken)} cannot be null", nameof(header)); + } + + // ensure we have the aps node + if (data == null) throw new ArgumentNullException(nameof(data)); + if (data.Aps == null) throw new ArgumentException($"{nameof(data.Aps)} cannot be null", nameof(data)); + + var path = $"/3/device/{header.DeviceToken}"; + var uri = new Uri(endpoint, path); + var request = new HttpRequestMessage(HttpMethod.Post, uri) + { + Version = new Version(2, 0), // APNs requires HTTP/2 + Content = MakeJsonContent(data, jsonTypeInfo), + }; + + // specify the header for content we can accept + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + + // add header specific to APNs + request.Headers.TryAddWithoutValidation(":method", "POST"); + request.Headers.TryAddWithoutValidation(":path", path); + + // add an Id if specified + if (header.Id != null) + { + request.Headers.Add("apns-id", header.Id.ToString()!.ToLower()); + } + + // add expiration in UNIX seconds since Epoch. + var expiration = header.Expiration?.ToUnixTimeSeconds() ?? 0; + request.Headers.Add("apns-expiration", expiration.ToString()); + + // add priority as an int: 10,5. + var priority = (int)header.Priority; + request.Headers.Add("apns-priority", priority.ToString()); + + // add the push type (string, all lowercase) + var type = header.PushType.ToString().ToLower(); + request.Headers.Add("apns-push-type", type); + + // add the topic + // The topic of the remote notification, which is typically the bundle ID for the app. + // The certificate created in the developer account must include the capability for this topic. + // If the certificate includes multiple topics, you value for this header must be specified. + // If this request header is omitted and the APNs certificate does not specify multiple topics, + // the APNs server uses the certificate’s Subject as the default topic. + // If a provider token ins used instead of a certificate, a value for this request header must be specified. + // The topic provided should be provisioned for the team named in your developer account. + // For certain types of push, the topic needs to be suffixed with a pre-defined string + var topic = Options.BundleId; + topic += GetTopicSuffixFromPushType(header.PushType); + request.Headers.Add("apns-topic", topic); + + // add a collapseId if specified + if (!string.IsNullOrEmpty(header.CollapseId)) + { + // ensure the value is not longer than 64 bytes + var value = header.CollapseId[..Math.Min(header.CollapseId.Length, 64)]; + request.Headers.Add("apns-collapse-id", value); + } + + return await SendAsync(request, SC.Default.ApnsMessageResponse, SC.Default.ApnsResponseError, cancellationToken).ConfigureAwait(false); + } + + internal static string? GetTopicSuffixFromPushType(ApnsPushType type) + { + return type switch + { + ApnsPushType.Voip => ".voip", + ApnsPushType.Complication => ".complication", + ApnsPushType.FileProvider => ".pushkit.fileprovider", + _ => null, + }; + } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierConfigureOptions.cs b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierConfigureOptions.cs new file mode 100644 index 0000000..1e4d13f --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierConfigureOptions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +internal class ApnsNotifierConfigureOptions : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, ApnsNotifierOptions options) + { + // ensure we have a BundleId + if (string.IsNullOrEmpty(options.BundleId)) + { + return ValidateOptionsResult.Fail($"{nameof(options.BundleId)} must be provided"); + } + + // ensure we have a PrivateKeyBytes resolver + if (options.PrivateKeyBytes is null) + { + return ValidateOptionsResult.Fail($"{nameof(options.PrivateKeyBytes)} must be provided"); + } + + // ensure we have a KeyId + if (string.IsNullOrEmpty(options.KeyId)) + { + return ValidateOptionsResult.Fail($"{nameof(options.KeyId)} must be provided"); + } + + // ensure we have a TeamId + if (string.IsNullOrEmpty(options.TeamId)) + { + return ValidateOptionsResult.Fail($"{nameof(options.TeamId)} must be provided"); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptions.cs b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptions.cs new file mode 100644 index 0000000..a091d5c --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptions.cs @@ -0,0 +1,32 @@ +using Tingle.Extensions.Http; +using Tingle.Extensions.PushNotifications.Apple; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Configuration options for . +/// +public class ApnsNotifierOptions : AbstractHttpApiClientOptions +{ + /// + /// Gets or sets a delegate to get the raw bytes of the private + /// key which is passed in the value of . + /// + /// The private key should be in PKCS #8 (.p8) format. + public virtual Func>? PrivateKeyBytes { get; set; } + + /// + /// Gets or sets the ID for your Apple Push Notifications private key. + /// + public virtual string? KeyId { get; set; } + + /// + /// Gets or sets the Team ID for your Apple Developer account. + /// + public virtual string? TeamId { get; set; } + + /// + /// Gets or sets the bundle ID for your app (iOS, watchOS, tvOS iPadOS, etc). + /// + public virtual string? BundleId { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptionsExtensions.cs b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptionsExtensions.cs new file mode 100644 index 0000000..f169676 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptionsExtensions.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.FileProviders; +using System.Security.Cryptography; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for . +/// +public static class ApnsNotifierOptionsExtensions +{ + /// + /// Configures the application to use a specified private to generate a token for the notifier. + /// + /// The Apple push notification options to configure. + /// + /// A delegate to a method to return the for the private key + /// which is passed in the value of . + /// + /// + public static ApnsNotifierOptions UsePrivateKey(this ApnsNotifierOptions options, + Func privateKeyFileResolver) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + if (privateKeyFileResolver == null) throw new ArgumentNullException(nameof(privateKeyFileResolver)); + + options.PrivateKeyBytes = async (keyId) => + { + var fileInfo = privateKeyFileResolver(keyId); + + using var stream = fileInfo.CreateReadStream(); + using var reader = new StreamReader(stream); + + var privateKey = await reader.ReadToEndAsync().ConfigureAwait(false); + return ParsePrivateKey(privateKey); + }; + + return options; + } + + /// + /// Configures the application to use a specified private to generate a token for the notifier. + /// + /// The Apple push notification options to configure. + /// + /// A delegate to a method to return the private key which is passed in the value of + /// . + /// + /// + public static ApnsNotifierOptions UsePrivateKey(this ApnsNotifierOptions options, + Func privateKeyResolver) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + if (privateKeyResolver == null) throw new ArgumentNullException(nameof(privateKeyResolver)); + + options.PrivateKeyBytes = (keyId) => + { + var privateKey = privateKeyResolver(keyId); + return Task.FromResult(ParsePrivateKey(privateKey)); + }; + + return options; + } + + internal static byte[] ParsePrivateKey(string privateKey) + { + if (privateKey is null) throw new ArgumentNullException(nameof(privateKey)); + + // exclude the PEM header if present + if (PemEncoding.TryFind(privateKey, out var pem)) + { + privateKey = privateKey[pem.Base64Data]; + } + + return Convert.FromBase64String(privateKey); + } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsAlert.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsAlert.cs new file mode 100644 index 0000000..e8b0772 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsAlert.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// Represents the alert delivered to the device +/// +public class ApnsAlert +{ + /// + /// A short string describing the purpose of the notification. Apple Watch displays this + /// string as part of the notification interface. This string is displayed only briefly + /// and should be crafted so that it can be understood quickly. + /// + /// This key was added in iOS 8.2. + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The text of the alert message. + /// + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// + /// The key to a title string in the Localizable.strings file for the current localization. + /// The key string can be formatted with %@ and %n$@ specifiers to take the variables + /// specified in . + /// + /// See Localizing the Content of Your Remote Notifications for more information. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 + /// + /// This key was added in iOS 8.2. + [JsonPropertyName("title-loc-key")] + public string? TitleLocalizationKey { get; set; } + + /// + /// Variable string values to appear in place of the format specifiers in . + /// + /// See Localizing the Content of Your Remote Notifications for more information. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 + /// + /// This key was added in iOS 8.2. + [JsonPropertyName("title-loc-args")] + public ICollection? TitleLocalizationArgs { get; set; } + + /// + /// If specified, the system displays an alert that includes the Close and View buttons. + /// The set value is used as a key to get a localized string in the current localization + /// to use for the right button’s title instead of "View". + /// + /// See Localizing the Content of Your Remote Notifications for more information. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 + /// + [JsonPropertyName("action-loc-key")] + public string? ActionLocalizationKey { get; set; } + + /// + /// The key to an alert-message string in a Localizable.strings file for the current + /// localization (which is set by the user’s language preference). The key string can be + /// formatted with %@ and %n$@ specifiers to take the variables specified in + /// . + /// + /// See Localizing the Content of Your Remote Notifications for more information. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 + /// + [JsonPropertyName("loc-key")] + public string? LocalizationKey { get; set; } + + /// + /// Variable string values to appear in place of the format specifiers in . + /// + /// See Localizing the Content of Your Remote Notifications for more information. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 + /// + [JsonPropertyName("loc-args")] + public ICollection? LocalizationArgs { get; set; } + + /// + /// The filename of an image file in the app bundle, with or without the filename extension. + /// The image is used as the launch image when users tap the action button or move the + /// action slider. If this property is not specified, the system either uses the previous + /// snapshot, uses the image identified by the UILaunchImageFile key in the app’s + /// Info.plist file, or falls back to Default.png. + /// + [JsonPropertyName("launch-image")] + public string? LaunchImage { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsEnvironment.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsEnvironment.cs new file mode 100644 index 0000000..13f34a9 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsEnvironment.cs @@ -0,0 +1,17 @@ +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// The APNs environment to send the notification to. +/// +public enum ApnsEnvironment +{ + /// + /// Represents the development environment and must be used with device tokens registered on a similar environment. + /// + Development, + + /// + /// Represents the production environment and must be used with device tokens registered on a similar environment. + /// + Production +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsErrorReason.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsErrorReason.cs new file mode 100644 index 0000000..44a320f --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsErrorReason.cs @@ -0,0 +1,158 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// Represents a reason why an APNs request failed +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ApnsErrorReason +{ + /// + /// The collapse identifier exceeds the maximum allowed size + /// + BadCollapseId, + + /// + /// The specified device token was bad. Verify that the request contains + /// a valid token and that the token matches the environment. + /// + BadDeviceToken, + + /// + /// The apns-expiration value is bad. + /// + BadExpirationDate, + + /// + /// The apns-id value is bad. + /// + BadMessageId, + + /// + /// The apns-priority value is bad. + /// + BadPriority, + + /// + /// The apns-topic value is bad. + /// + BadTopic, + + /// + /// The device token does not match the specified topic. + /// + DeviceTokenNotForTopic, + + /// + /// One or more headers were repeated. + /// + DuplicateHeaders, + + /// + /// Idle time out. + /// + IdleTimeout, + + /// + /// The device token is not specified in the request :path. Verify + /// that the :path header contains the device token. + /// + MissingDeviceToken, + + /// + /// The apns-topic header of the request was not specified and was + /// required. The apns-topic header is mandatory when the client + /// is connected using a certificate that supports multiple topics. + /// + MissingTopic, + + /// + /// The message payload was empty. + /// + PayloadEmpty, + + /// + /// Pushing to this topic is not allowed. + /// + TopicDisallowed, + + /// + /// The certificate was bad. + /// + BadCertificate, + + /// + /// The client certificate was for the wrong environment. + /// + BadCertificateEnvironment, + + /// + /// The provider token is stale and a new token should be generated. + /// + ExpiredProviderToken, + + /// + /// The specified action is not allowed. + /// + Forbidden, + + /// + /// The provider token is not valid or the token signature could not be verified. + /// + InvalidProviderToken, + + /// + /// No provider certificate was used to connect to APNs and Authorization + /// header was missing or no provider token was specified. + /// + MissingProviderToken, + + /// + /// The request contained a bad :path value. + /// + BadPath, + + /// + /// The specified :method was not POST. + /// + MethodNotAllowed, + + /// + /// The device token is inactive for the specified topic. + /// + Unregistered, + + /// + /// The message payload was too large. See + /// Creating the Remote Notification Payload + /// (https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW1) + /// for details on maximum payload size. + /// + PayloadTooLarge, + + /// + /// The provider token is being updated too often. + /// + TooManyProviderTokenUpdates, + + /// + /// Too many requests were made consecutively to the same device token. + /// + TooManyRequests, + + /// + /// An internal server error occurred. + /// + InternalServerError, + + /// + /// The service is unavailable. + /// + ServiceUnavailable, + + /// + /// The server is shutting down. + /// + Shutdown, +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageData.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageData.cs new file mode 100644 index 0000000..4b7d316 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageData.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// Represents the data actually sent to the device. +/// If you need to send more information, inherit from this class. +/// +public class ApnsMessageData +{ + /// + /// The payload for the push as specified by Apple + /// + [JsonPropertyName("aps")] + public ApnsMessagePayload Aps { get; set; } = new ApnsMessagePayload { }; +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageHeader.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageHeader.cs new file mode 100644 index 0000000..d360346 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageHeader.cs @@ -0,0 +1,57 @@ +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// A header for a message to be sent to APNs +/// +public sealed class ApnsMessageHeader +{ + /// + /// A unique identifier for the message. If there is + /// an error sending the notification, APNs uses this + /// value to identify the notification to your server. + /// If set to null, a new value is created by APNs + /// and returned in the response. + /// + public Guid? Id { get; set; } + + /// + /// This identifies the date when the notification is no longer valid and can be discarded. + /// If this value is not null, APNs stores the notification and tries to deliver it at least once, + /// repeating the attempt as needed if it is unable to deliver the notification the first time. + /// + /// If the value is null, APNs treats the notification as if it expires immediately and does not + /// store the notification or attempt to redeliver it. + /// + public DateTimeOffset? Expiration { get; set; } + + /// + /// The priority of the notification. + /// Defaults to . + /// + public ApnsPriority Priority { get; set; } = ApnsPriority.Immediately; + + /// + /// Multiple notifications with the same collapse identifier are displayed to the user as a + /// single notification. The value of this key must not exceed 64 bytes. For more information, see + /// Quality of Service, Store-and-Forward, and Coalesced Notifications at + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/APNSOverview.html#//apple_ref/doc/uid/TP40008194-CH8-SW5 + /// + public string? CollapseId { get; set; } + + /// + /// The environment to send the notification to. + /// Defaults to . + /// + public ApnsEnvironment Environment { get; set; } = ApnsEnvironment.Production; + + /// + /// The type of push notification. + /// Defaults to . + /// + public ApnsPushType PushType { get; set; } = ApnsPushType.Background; + + /// + /// The token for the device to send the message to + /// + public string? DeviceToken { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessagePayload.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessagePayload.cs new file mode 100644 index 0000000..47a2c7b --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessagePayload.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// Represents a payload for a push notification as specified by Apple +/// +public class ApnsMessagePayload +{ + /// + /// Set a value when you want the system to display a standard alert or a banner. + /// The notification settings for your app on the user’s device determine + /// whether an alert or banner is displayed. + /// + [JsonPropertyName("alert")] + public ApnsAlert Alert { get; set; } = new ApnsAlert { }; + + /// + /// Set a value when you want the system to modify the badge of your app icon. + /// If value is null, the badge is not changed. + /// To remove the badge, set this property to 0. + /// Defaults to + /// + [JsonPropertyName("badge")] + public int? Badge { get; set; } + + /// + /// Set this value when you want the system to play a sound. The value set is the + /// name of a sound file in your app’s main bundle or in the Library/Sounds + /// folder of your app’s data container. If the sound file cannot be found, or if + /// you specify default for the value, the system plays the default alert sound. + /// + /// For details about providing sound files for notifications, + /// see Preparing Custom Alert Sounds. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/SupportingNotificationsinYourApp.html#//apple_ref/doc/uid/TP40008194-CH4-SW10 + /// + [JsonPropertyName("sound")] + public string? Sound { get; set; } + + /// + /// Set this value to 1 to configure a background update notification. + /// When a value is set, the system wakes up your app in the background and delivers + /// the notification to its app delegate. + /// + /// For information about configuring and handling background update notifications, + /// see Configuring a Background Update Notification. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW8 + /// + [JsonPropertyName("content-available")] + public int? ContentAvailable { get; set; } + + /// + /// Set a value that represents the notification’s type. This value corresponds to the + /// value in the identifier property of one of your app’s registered categories. + /// + /// To learn more about using custom actions, see Configuring Categories and Actionable Notifications. + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/SupportingNotificationsinYourApp.html#//apple_ref/doc/uid/TP40008194-CH4-SW26 + /// + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// + /// Set a value that represents the app-specific identifier for grouping notifications. + /// If you provide a Notification Content app extension, you can use this value to group + /// your notifications together. For local notifications, this key corresponds to the + /// threadIdentifier property of the UNNotificationContent object. + /// + [JsonPropertyName("thread-id")] + public string? ThreadId { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageResponse.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageResponse.cs new file mode 100644 index 0000000..3fc2a8d --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsMessageResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// Represents the response from APNs +/// +public class ApnsMessageResponse +{ + // For a successful request, the body of the response is empty. + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPriority.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPriority.cs new file mode 100644 index 0000000..8b454eb --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPriority.cs @@ -0,0 +1,23 @@ +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// The priority of a message +/// +public enum ApnsPriority +{ + /// + /// Send the push message immediately. Notifications with this priority + /// must trigger an alert, sound, or badge on the target device. It is + /// an error to use this priority for a push notification that contains + /// only the content-available key. + /// + Immediately = 10, + + /// + /// Send the push message at a time that takes into account power + /// considerations for the device. Notifications with this priority + /// might be grouped and delivered in bursts. They are throttled, + /// and in some cases are not delivered. + /// + Normal = 5, +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPushType.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPushType.cs new file mode 100644 index 0000000..990f82e --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsPushType.cs @@ -0,0 +1,73 @@ +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// The type of push request being made. +/// It is required on watchOS 6 and later. It is recommended on macOS, iOS, tvOS, and iPadOS. +/// +public enum ApnsPushType +{ + /// + /// Use the alert push type for notifications that trigger a user interaction—for example, + /// an alert, badge, or sound. If you set this push type, the apns-topic header field must + /// use your app’s bundle ID as the topic. For more information, see + /// + /// Generating a Remote Notification + /// . + /// + Alert, + + /// + /// Use the background push type for notifications that deliver content in the background, and don’t + /// trigger any user interactions. If you set this push type, the apns-topic header field must + /// use your app’s bundle ID as the topic. For more information, see + /// + /// Pushing Background Updates to Your App + /// . + /// + Background, + + /// + /// Use the voip push type for notifications that provide information about an incoming Voice-over-IP + /// (VoIP) call. For more information, see + /// + /// Responding to VoIP Notifications from PushKit + /// . + /// If you set this push type, the apns-topic header field must use your app’s bundle ID with .voip + /// appended to the end. If you’re using certificate-based authentication, you must also register the certificate + /// for VoIP services. The topic is then part of the 1.2.840.113635.100.6.3.4 or 1.2.840.113635.100.6.3.6 + /// extension. + /// + Voip, + + /// + /// Use the complication push type for notifications that contain update information for a watchOS app’s + /// complications. For more information, see + /// + /// Updating Your Timeline + /// . + /// If you set this push type, the apns-topic header field must use your app’s bundle ID with .complication + /// appended to the end. If you’re using certificate-based authentication, you must also register the certificate + /// for WatchKit services. The topic is then part of the 1.2.840.113635.100.6.3.6 extension. + /// + Complication, + + /// + /// Use the fileprovider push type to signal changes to a File Provider extension. If you set this push type, + /// the apns-topic header field must use your app’s bundle ID with .pushkit.fileprovider appended + /// to the end. For more information, see + /// + /// Using Push Notifications to Signal Changes + /// . + /// + FileProvider, + + /// + /// Use the mdm push type for notifications that tell managed devices to contact the MDM server. If you set + /// this push type, you must use the topic from the UID attribute in the subject of your MDM push certificate. + /// For more information, see + /// + /// Device Management + /// . + /// + Mdm, +} diff --git a/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsResponseError.cs b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsResponseError.cs new file mode 100644 index 0000000..cc67777 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Apple/Models/ApnsResponseError.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Apple.Models; + +/// +/// Represents an error response from APNs +/// +public class ApnsResponseError +{ + /// + /// The error indicating the reason for the failure. + /// + [JsonPropertyName("reason")] + public ApnsErrorReason Reason { get; set; } + + /// + /// If the value in the :status header is 410, the value + /// is the last time at which APNs confirmed that the device token was + /// no longer valid for the topic. + /// + /// Stop pushing notifications until the device registers a token with + /// a later timestamp with your provider. + /// + [JsonPropertyName("timestamp")] + public object? Timestamp { get; set; } + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyAuthenticationHandler.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyAuthenticationHandler.cs new file mode 100644 index 0000000..0a1e32c --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyAuthenticationHandler.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Tingle.Extensions.Http.Authentication; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy; + +/// +/// Implementation of for . +/// +internal class FcmLegacyAuthenticationHandler : AuthenticationHandler +{ + private readonly FcmLegacyNotifierOptions options; + + public FcmLegacyAuthenticationHandler(IOptionsSnapshot optionsAccessor) + { + options = optionsAccessor?.Value ?? throw new ArgumentNullException(nameof(optionsAccessor)); + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.TryAddWithoutValidation("Authorization", $"key={options.Key}"); + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifier.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifier.cs new file mode 100644 index 0000000..c10a655 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifier.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Tingle.Extensions.Http; +using Tingle.Extensions.PushNotifications.FcmLegacy.Models; +using SC = Tingle.Extensions.PushNotifications.PushNotificationsJsonSerializerContext; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy; + +/// +/// A push notification handler for Firebase Cloud Messaging Service +/// +public class FcmLegacyNotifier : AbstractHttpApiClient +{ + internal const string BaseUrl = "https://fcm.googleapis.com/fcm/send"; + + /// + /// Creates an instance of + /// + /// the client for making requests + /// the accessor for the configuration options + public FcmLegacyNotifier(HttpClient httpClient, IOptionsSnapshot optionsAccessor) + : base(httpClient, optionsAccessor) { } + + /// + /// Send a push notifications via Firebase Cloud Messaging (FCM) + /// + /// the message + /// + /// + public virtual async Task> SendAsync(FcmLegacyRequest message, + CancellationToken cancellationToken = default) + { + var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl) { Content = MakeJsonContent(message, SC.Default.FcmLegacyRequest), }; + return await SendAsync(request, SC.Default.FcmLegacyResponse, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierConfigureOptions.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierConfigureOptions.cs new file mode 100644 index 0000000..548d5a5 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierConfigureOptions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +internal class FcmLegacyNotifierConfigureOptions : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, FcmLegacyNotifierOptions options) + { + // ensure we have a key + if (string.IsNullOrEmpty(options.Key)) + { + return ValidateOptionsResult.Fail($"{nameof(options.Key)} must be provided"); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierOptions.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierOptions.cs new file mode 100644 index 0000000..a2c505b --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/FcmLegacyNotifierOptions.cs @@ -0,0 +1,15 @@ +using Tingle.Extensions.Http; +using Tingle.Extensions.PushNotifications.FcmLegacy; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Configuration options for +/// +public class FcmLegacyNotifierOptions : AbstractHttpApiClientOptions +{ + /// + /// The authentication key for Firebase + /// + public virtual string? Key { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyErrorCode.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyErrorCode.cs new file mode 100644 index 0000000..94c9802 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyErrorCode.cs @@ -0,0 +1,122 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents a reason why an FCM request failed in the legacy HTTP API. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FcmLegacyErrorCode +{ + /// + /// Check that the request contains a registration token. + /// Specified via or field. + /// + MissingRegistration, + + /// + /// Check the format of the registration token you pass to the server. + /// Make sure it matches the registration token the client app receives from registering with Firebase Notifications. + /// Do not truncate or add additional characters. + /// + InvalidRegistration, + + /// + /// An existing registration token may cease to be valid in a number of scenarios, including: + /// + /// If the client app unregisters with FCM. + /// + /// If the client app is automatically unregistered, which can happen if the user uninstalls the application. + /// For example, on iOS, if the APNs Feedback Service reported the APNs token as invalid. + /// + /// + /// If the registration token expires (for example, Google might decide to refresh registration tokens, or the APNs token has expired for iOS devices). + /// + /// + /// If the client app is updated but the new version is not configured to receive messages. + /// + /// + ///
+ /// For all these cases, remove this registration token from the app server and stop using it to send messages. + ///
+ NotRegistered, + + /// + /// Make sure the message was addressed to a registration token whose package name matches the value passed in the request. + /// + InvalidPackageName, + + /// + /// A registration token is tied to a certain group of senders. + /// When a client app registers for FCM, it must specify which senders are allowed to send messages. + /// You should use one of those sender IDs when sending messages to the client app. + /// If you switch to a different sender, the existing registration tokens won't work. + /// + MismatchSenderId, + + /// + /// Check that the total size of the payload data included in a message does not exceed + /// FCM limits: 4096 bytes for most messages, or 2048 bytes in the case of messages to topics. + /// This includes both the keys and the values. + /// + MessageTooBig, + + /// + /// Check that the payload does not contain a key + /// (such as from, or gcm, or any value prefixed by google) that is used internally by FCM. + /// Note that some words (such as collapse_key) are also used by FCM but are allowed in the payload, + /// in which case the payload value will be overridden by the FCM value. + /// + InvalidDataKey, + + /// + /// Check that the value used in is an integer representing + /// a duration in seconds between 0 and 2,419,200 (4 weeks). + /// + InvalidTtl, + + /// + /// The server couldn't process the request in time. Retry the same request, but you must: + /// + /// Honor the Retry-After header if it is included in the response from the FCM Connection Server. + /// + /// + /// Implement exponential back-off in your retry mechanism. + /// (e.g. if you waited one second before the first retry, wait at least two second before the next one, then 4 seconds and so on). + /// If you're sending multiple messages, delay each one independently by an additional random amount to + /// avoid issuing a new request for all messages at the same time. + /// Senders that cause problems risk being blacklisted. + /// + /// + Unavailable, + + /// + /// The server encountered an error while trying to process the request. + /// You could retry the same request following the requirements listed in . + /// If the error persists, please contact Firebase support. + /// + InternalServerError, + + /// + /// The rate of messages to a particular device is too high. + /// If an iOS app sends messages at a rate exceeding APNs limits, it may receive this error message. + ///
+ /// Reduce the number of messages sent to this device and use + /// exponential back-off to retry sending. + ///
+ DeviceMessageRateExceeded, + + /// + /// The rate of messages to subscribers to a particular topic is too high. + /// Reduce the number of messages sent for this topic and use + /// exponential back-off to retry sending. + /// + TopicsMessageRateExceeded, + + /// + /// A message targeted to an iOS device could not be sent because the required APNs authentication key + /// was not uploaded or has expired. + /// Check the validity of your development and production credentials. + /// + InvalidApnsCredential, +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotification.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotification.cs new file mode 100644 index 0000000..a9b040a --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotification.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Abstractions for an FCM notification using legacy HTTP API. +/// +public abstract class FcmLegacyNotification +{ + /// + /// The notification's title. + /// This field is not visible on iOS phones and tablets. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The notification's body text. + /// + [JsonPropertyName("body")] + public string? Body { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationAndroid.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationAndroid.cs new file mode 100644 index 0000000..fe5d761 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationAndroid.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents an for Android in the legacy HTTP API. +/// +public class FcmLegacyNotificationAndroid : FcmLegacyNotification +{ + /// + /// The notification's channel id (new in Android O). + ///
+ /// The app must create a channel with this channel ID before any notification with this channel ID is received. + ///
+ /// If you don't send this channel ID in the request, or if the channel ID provided has not yet been created by the app, + /// FCM uses the channel ID specified in the app manifest. + ///
+ [JsonPropertyName("android_channel_id")] + public string? AndroidChannelId { get; set; } + + /// + /// The notification's icon. + /// Sets the notification icon to myicon for drawable resource myicon. + /// If you don't send this key in the request, FCM displays the launcher icon specified in your app manifest. + /// + [JsonPropertyName("icon")] + public string? Icon { get; set; } + + /// + /// The sound to play when the device receives the notification. + /// Supports default or the filename of a sound resource bundled in the app. + /// Sound files must reside in /res/raw/ folder. + /// + [JsonPropertyName("sound")] + public string? Sound { get; set; } + + /// + /// Identifier used to replace existing notifications in the notification drawer. + /// If not specified, each request creates a new notification. + /// If specified and a notification with the same tag is already being shown, + /// the new notification replaces the existing one in the notification drawer. + /// + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + /// + /// The notification's icon color, expressed in #rrggbb format. + /// + [JsonPropertyName("color")] + public string? Color { get; set; } + + /// + /// The action associated with a user click on the notification. + /// If specified, an activity with a matching intent filter is launched when a user clicks on the notification. + /// + [JsonPropertyName("click_action")] + public string? ClickAction { get; set; } + + /// + /// The key to the body string in the app's string resources to use to localize the body text to the user's current localization. + /// + /// + /// See String Resources for more information. + /// + [JsonPropertyName("body_loc_key")] + public string? BodyLocalizationKey { get; set; } + + /// + /// Variable string values to be used in place of the format specifiers in + /// to use to localize the body text to the user's current localization. + /// + /// + /// See Formatting and Styling + /// for more information. + /// + [JsonPropertyName("body_loc_args")] + public ICollection? BodyLocalizationArgs { get; set; } + + /// + /// The key to the title string in the app's string resources to use to localize the title text to the user's current localization. + /// + /// + /// See String Resources for more information. + /// + [JsonPropertyName("title_loc_key")] + public string? TitleLocalizationKey { get; set; } + + /// + /// Variable string values to be used in place of the format specifiers in + /// to use to localize the title text to the user's current localization. + /// + /// + /// See Formatting and Styling + /// for more information. + /// + [JsonPropertyName("title_loc_args")] + public ICollection? TitleLocalizationArgs { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationIos.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationIos.cs new file mode 100644 index 0000000..4c1008b --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationIos.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents an for iOS. +/// +public class FcmLegacyNotificationIos : FcmLegacyNotification +{ + /// + /// The sound to play when the device receives the notification. + ///
+ /// String specifying sound files in the main bundle of the client app or in the Library/Sounds folder of the app's data container. + ///
+ /// + /// See the iOS Developer Library for more information. + /// + [JsonPropertyName("sound")] + public string? Sound { get; set; } + + /// + /// The value of the badge on the home screen app icon. + /// If not specified, the badge is not changed. + /// If set to 0, the badge is removed. + /// + [JsonPropertyName("badge")] + public string? Badge { get; set; } + + /// + /// The action associated with a user click on the notification. + /// Corresponds to category in the APNs payload. + /// + [JsonPropertyName("click_action")] + public string? ClickAction { get; set; } + + /// + /// The notification's subtitle. + /// + [JsonPropertyName("subtitle")] + public string? Subtitle { get; set; } + + /// + /// The key to the body string in the app's string resources to use to localize the body text to the user's current localization. + /// Corresponds to loc-key in the APNs payload. + /// + /// + /// See + /// Payload Key Reference + /// and + /// Localizing the Content of Your Remote Notifications for more information. + /// + [JsonPropertyName("body_loc_key")] + public string? BodyLocalizationKey { get; set; } + + /// + /// Variable string values to be used in place of the format specifiers in + /// to use to localize the body text to the user's current localization. + /// Corresponds to loc-args in the APNs payload. + /// + /// + /// See + /// Payload Key Reference + /// and + /// Localizing the Content of Your Remote Notifications for more information. + /// + [JsonPropertyName("body_loc_args")] + public ICollection? BodyLocalizationArgs { get; set; } + + /// + /// The key to the title string in the app's string resources to use to localize the title text to the user's current localization. + /// Corresponds to title-loc-key in the APNs payload. + /// + /// + /// See + /// Payload Key Reference + /// and + /// Localizing the Content of Your Remote Notifications for more information. + /// + [JsonPropertyName("title_loc_key")] + public string? TitleLocalizationKey { get; set; } + + /// + /// Variable string values to be used in place of the format specifiers in + /// to use to localize the title text to the user's current localization. + /// Corresponds to title-loc-args in the APNs payload. + /// + /// + /// See + /// Payload Key Reference + /// and + /// Localizing the Content of Your Remote Notifications for more information. + /// + [JsonPropertyName("title_loc_args")] + public ICollection? TitleLocalizationKeyArgs { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationWeb.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationWeb.cs new file mode 100644 index 0000000..25f889b --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyNotificationWeb.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents an for Web (i.e. Chrome). +/// +public class FcmLegacyNotificationWeb : FcmLegacyNotification +{ + /// + /// The URL to use for the notification's icon. + /// + [JsonPropertyName("icon")] + public string? Icon { get; set; } + + /// + /// The action associated with a user click on the notification. + /// For all URL values, HTTPS is required. + /// + [JsonPropertyName("click_action")] + public string? ClickAction { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyPriority.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyPriority.cs new file mode 100644 index 0000000..dc6a3dc --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyPriority.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents the priority of an FCM request in the legacy HTTP API. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FcmLegacyPriority +{ + /// + /// Optimized battery consumption on device. + /// Corresponds to priority: 5 on APNS + /// + Normal, + + /// + /// Delivered to device immediately. + /// Corresponds to priority: 10 on APNS + /// + High, +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyRequest.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyRequest.cs new file mode 100644 index 0000000..8d5ca14 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyRequest.cs @@ -0,0 +1,162 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents a request payload sent to Firebase Cloud Messaging (FCM) using the legacy HTTP API. +/// +public class FcmLegacyRequest +{ + /// + /// The recipient of a message. + /// It can be a device's registration token, a device group's notification key, + /// or a single topic(prefixed with /topics/). + /// To send to multiple topics, use the parameter. + /// + [JsonPropertyName("to")] + public string? To { get; set; } + + /// + /// The registration tokens to be targeted. Can be ignored if is used + /// The recipient of a multicast message, a message sent to more than one registration token. + /// The value should be an array of registration tokens to which to send the multicast message. + /// The array must contain at least 1 and at most 1000 registration tokens. + /// To send a message to a single device, use the parameter. + /// + [JsonPropertyName("registration_ids")] + public IEnumerable? RegistrationIds { get; set; } + + /// + /// Specifies a logical expression of conditions that determine the message target. + ///
+ /// Supported condition: Topic, formatted as 'yourTopic' in topics. This value is case-insensitive. + ///
+ /// Supported operators: &&, ||. Maximum two operators per topic message supported. + ///
+ [JsonPropertyName("condition")] + public string? Condition { get; set; } + + /// + /// Identifies a group of messages (e.g., with Updates Available) that can be collapsed, + /// so that only the last message gets sent when delivery can be resumed. + /// This is intended to avoid sending too many of the same messages when the device comes back online or becomes active. + ///
+ /// Note that there is no guarantee of the order in which messages get sent. + ///
+ /// Note: A maximum of 4 different collapse keys are allowed at any given time. + /// This means an FCM connection server can simultaneously store 4 different messages per client app. + /// If you exceed this number, there is no guarantee which 4 collapse keys the FCM connection server will keep. + ///
+ [JsonPropertyName("collapse_key")] + public string? CollapseKey { get; set; } + + /// + /// The priority of the message + /// By default, notification messages are sent with priority, + /// and data messages are sent with priority. + /// priority optimizes the client app's battery consumption + /// and should be used unless immediate delivery is required. + /// For messages with priority, + /// the app may receive the message with unspecified delay. + ///
+ /// When a message is sent with priority, it is sent immediately, + /// and the app can display a notification. + ///
+ [JsonPropertyName("priority")] + public FcmLegacyPriority? Priority { get; set; } + + /// + /// On iOS, use this field to represent content-available in the APNs payload. + /// When a notification or message is sent and this is set to , an inactive client app is awoken, + /// and the message is sent through APNs as a silent notification and not through the FCM connection server. + ///
+ /// Note that silent notifications in APNs are not guaranteed to be delivered, and can depend on factors such + /// as the user turning on Low Power Mode, force quitting the app, etc. + /// On Android, data messages wake the app by default. + /// On Chrome, currently not supported. + ///
+ [JsonPropertyName("content_available")] + public bool? ContentAvailable { get; set; } + + /// + /// Currently for iOS 10+ devices only. + /// On iOS, use this field to represent mutable-content in the APNs payload. + /// When a notification is sent and this is set to , the content of the notification + /// can be modified before it is displayed, using a + /// Notification Service app extension. + ///
+ /// This will be ignored for Android and web. + ///
+ [JsonPropertyName("mutable_content")] + public bool? MutableContent { get; set; } + + /// + /// Specifies how long (in seconds) the message should be kept in FCM storage if the device is offline. + /// The maximum time to live supported is 4 weeks, and the default value is 4 weeks. For more information, + /// see Setting the lifespan of a message. + /// + [JsonPropertyName("time_to_live")] + public long? TtlSeconds { get; set; } + + /// + /// Specifies the package name of the application where the registration tokens must match in order to receive the message. + /// (Android Only) + /// + [JsonPropertyName("restricted_package_name")] + public string? RestrictedPackageName { get; set; } + + /// + /// When set to true, allows developers to test a request without actually sending a message. + /// Defaults to + /// + [JsonPropertyName("dry_run")] + public bool? DryRun { get; set; } + + /// + /// Specifies the custom key-value pairs of the message's payload. + ///
+ /// For example, with data:{"score":"3x1"}: + ///
+ /// On iOS, if the message is sent via APNs, it represents the custom data fields. + /// If it is sent via FCM connection server, it would be represented as key value + /// dictionary in AppDelegate application:didReceiveRemoteNotification:. + ///
+ /// On Android, this would result in an intent extra named score with the string value 3x1. + /// The key should not be a reserved word (from, message_type, or any word starting with google or gcm). + /// Do not use any of the json property names defined in this class (such as collapse_key). + ///
+ [JsonPropertyName("data")] + public IDictionary? Data { get; set; } +} + +/// +/// Represents a request payload sent to Firebase Cloud Messaging (FCM) using the legacy HTTP API. +/// +/// The type for use with the property. +public class FcmLegacyRequest : FcmLegacyRequest where TNotification : FcmLegacyNotification, new() // using the generic type solves a serialization issue with System.Text.Json +{ + /// + /// This parameter specifies the predefined, user-visible key-value pairs of the notification payload. + /// See Notification payload support for detail. For more information about notification message and data message options, see + /// Message types. + /// If a notification payload is provided, or the option is set to + /// for a message to an iOS device, the message is sent through APNs, otherwise it is sent through the FCM connection server. + /// + [JsonPropertyName("notification")] + public TNotification? Notification { get; set; } +} + +/// +/// Represents a request payload sent to Firebase Cloud Messaging (FCM) using the legacy HTTP API to Android. +/// +public class FcmLegacyRequestAndroid : FcmLegacyRequest { } + +/// +/// Represents a request payload sent to Firebase Cloud Messaging (FCM) using the legacy HTTP API to iOS. +/// +public class FcmLegacyRequestIos : FcmLegacyRequest { } + +/// +/// Represents a request payload sent to Firebase Cloud Messaging (FCM) using the legacy HTTP API to Web. +/// +public class FcmLegacyRequestWeb : FcmLegacyRequest { } diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResponse.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResponse.cs new file mode 100644 index 0000000..ebe45bc --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResponse.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents a response payload received from Firebase Cloud Messaging (FCM) in the legacy HTTP API. +/// +public class FcmLegacyResponse +{ + /// + /// Unique ID (number) identifying the multicast message. + /// + [JsonPropertyName("multicast_id")] + public long MulticastId { get; set; } + + /// + /// Number of messages that were processed without an error. + /// + [JsonPropertyName("success")] + public long Success { get; set; } + + /// + /// Number of messages that could not be processed. + /// + [JsonPropertyName("failure")] + public long Failure { get; set; } + + /// + /// Array of objects representing the status of the messages processed. + /// The objects are listed in the same order as the request + /// (i.e., for each registration ID in the request, its result is listed in the same index in the response). + /// + [JsonPropertyName("results")] + public IList? Results { get; set; } + + /// + /// The topic message ID when FCM has successfully received the request and will attempt to deliver to all subscribed devices. + /// Only populated for responses from topic request. + /// + [JsonPropertyName("message_id")] + public long? MessageId { get; set; } + + /// + /// String specifying the error that occurred when processing the message for the recipient. + /// Only populated for responses from topic request. + /// + [JsonPropertyName("error")] + public FcmLegacyErrorCode? Error { get; set; } + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResult.cs b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResult.cs new file mode 100644 index 0000000..56ee8a7 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/FcmLegacy/Models/FcmLegacyResult.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents the result of each sent message using the legacy HTTP API. +/// +public class FcmLegacyResult +{ + /// + /// String specifying a unique ID for each successfully processed message. + /// + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + /// + /// String specifying the error that occurred when processing the message for the recipient. + /// + [JsonPropertyName("error")] + public FcmLegacyErrorCode? Error { get; set; } + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseAuthenticationHandler.cs b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseAuthenticationHandler.cs new file mode 100644 index 0000000..02e4ed6 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseAuthenticationHandler.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using Tingle.Extensions.Http.Authentication; + +namespace Tingle.Extensions.PushNotifications.Firebase; + +/// +/// Implementation of for . +/// +internal class FirebaseAuthenticationHandler : OAuthClientCredentialHandler +{ + private const string Scope = "https://www.googleapis.com/auth/firebase.messaging"; + + private readonly FirebaseNotifierOptions options; + + public FirebaseAuthenticationHandler(IMemoryCache cache, IOptionsSnapshot optionsAccessor, ILogger logger) + : this(cache, optionsAccessor, logger, null) { } + + internal FirebaseAuthenticationHandler(IMemoryCache cache, IOptionsSnapshot optionsAccessor, ILogger logger, HttpClient? backChannel) + : base(backChannel) + { + Scheme = "Bearer"; + Cache = new(cache); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + options = optionsAccessor?.Value ?? throw new ArgumentNullException(nameof(optionsAccessor)); + AuthenticationEndpoint = options.TokenUri; + } + + /// + public override string CacheKey => $"firebase:tokens:{options.ProjectId}"; + + /// + protected override Task RequestOAuthTokenAsync(HttpRequestMessage message, HttpClient backChannel, CancellationToken cancellationToken) + { + var assertion = GenerateAssertion(clientEmail: options.ClientEmail!, + tokenUri: options.TokenUri!, + privateKey: options.PrivateKey!, + issued: DateTimeOffset.UtcNow); + + // make OAuth2 request for a token + var parameters = new Dictionary + { + ["assertion"] = assertion, + ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", + }; + + return RequestOAuthTokenAsync(parameters, backChannel, cancellationToken); + } + + private static string GenerateAssertion(string clientEmail, string tokenUri, string privateKey, DateTimeOffset issued) + { + // prepare header + var header = new FirebaseAuthHeader("RS256", "JWT"); + var header_json = System.Text.Json.JsonSerializer.Serialize(header, PushNotificationsJsonSerializerContext.Default.FirebaseAuthHeader); + var headerBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(header_json)); + + // prepare payload + var expires = issued.AddHours(1); + var payload = new FirebaseAuthPayload(Issuer: clientEmail, + Scope: Scope, + Audience: tokenUri, + IssuedAtSeconds: issued.ToUnixTimeSeconds(), + ExpiresAtSeconds: expires.ToUnixTimeSeconds()); + var payload_json = System.Text.Json.JsonSerializer.Serialize(payload, PushNotificationsJsonSerializerContext.Default.FirebaseAuthPayload); + var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload_json)); + + // import key, https://stackoverflow.com/a/72661119 + using var rsa = RSA.Create(); + rsa.ImportFromPem(privateKey.AsSpan()); + + // sign data + var unsignedJwtData = $"{headerBase64}.{payloadBase64}"; + var signature = rsa.SignData(Encoding.UTF8.GetBytes(unsignedJwtData), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return $"{unsignedJwtData}.{Convert.ToBase64String(signature)}"; + } + + internal record FirebaseAuthHeader([property: JsonPropertyName("alg")] string? Algorithm, + [property: JsonPropertyName("typ")] string? Type); + internal record FirebaseAuthPayload([property: JsonPropertyName("iss")] string? Issuer, + [property: JsonPropertyName("scope")] string? Scope, + [property: JsonPropertyName("aud")] string? Audience, + [property: JsonPropertyName("iat")] long IssuedAtSeconds, + [property: JsonPropertyName("exp")] long ExpiresAtSeconds); +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifier.cs b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifier.cs new file mode 100644 index 0000000..102baef --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifier.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Tingle.Extensions.Http; +using Tingle.Extensions.PushNotifications.FcmLegacy; +using Tingle.Extensions.PushNotifications.Firebase.Models; +using SC = Tingle.Extensions.PushNotifications.PushNotificationsJsonSerializerContext; + +namespace Tingle.Extensions.PushNotifications.Firebase; + +/// +/// A push notification handler for Firebase Cloud Messaging Service +/// +public class FirebaseNotifier : AbstractHttpApiClient +{ + /// + /// Creates an instance of + /// + /// the client for making requests + /// the accessor for the configuration options + public FirebaseNotifier(HttpClient httpClient, IOptionsSnapshot optionsAccessor) + : base(httpClient, optionsAccessor) { } + + /// + /// Send a push notifications via Firebase Cloud Messaging (FCM) + /// + /// the message + /// + /// + public virtual async Task> SendAsync(FirebaseRequest message, + CancellationToken cancellationToken = default) + { + var url = $"https://fcm.googleapis.com/v1/projects/{Options.ProjectId}/messages:send"; + var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = MakeJsonContent(message, SC.Default.FirebaseRequest), }; + return await SendAsync(request, SC.Default.FirebaseResponse, SC.Default.FirebaseResponseProblem, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierConfigureOptions.cs b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierConfigureOptions.cs new file mode 100644 index 0000000..8a4fb1f --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierConfigureOptions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +internal class FirebaseNotifierConfigureOptions : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, FirebaseNotifierOptions options) + { + // ensure we have a ProjectId + if (string.IsNullOrEmpty(options.ProjectId)) + { + return ValidateOptionsResult.Fail($"{nameof(options.ProjectId)} must be provided"); + } + + // ensure we have a ClientEmail + if (string.IsNullOrEmpty(options.ClientEmail)) + { + return ValidateOptionsResult.Fail($"{nameof(options.ClientEmail)} must be provided"); + } + + // ensure we have a TokenUri + if (string.IsNullOrEmpty(options.TokenUri)) + { + return ValidateOptionsResult.Fail($"{nameof(options.TokenUri)} must be provided"); + } + + // ensure we have a PrivateKey + if (string.IsNullOrEmpty(options.PrivateKey)) + { + return ValidateOptionsResult.Fail($"{nameof(options.PrivateKey)} must be provided"); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptions.cs b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptions.cs new file mode 100644 index 0000000..45152bb --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptions.cs @@ -0,0 +1,30 @@ +using Tingle.Extensions.Http; +using Tingle.Extensions.PushNotifications.Firebase; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Configuration options for +/// +public class FirebaseNotifierOptions : AbstractHttpApiClientOptions +{ + /// + /// Gets or sets the Firebase project identifier. + /// + public virtual string? ProjectId { get; set; } + + /// + /// Gets or sets the client email for your Firebase Service Account. + /// + public virtual string? ClientEmail { get; set; } + + /// + /// Gets or sets the endpoint for requesting OAuth2 tokens. + /// + public virtual string? TokenUri { get; set; } + + /// + /// Gets or sets the private key for the Firebase Service Account. + /// + public virtual string? PrivateKey { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptionsExtensions.cs b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptionsExtensions.cs new file mode 100644 index 0000000..4e925ea --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/FirebaseNotifierOptionsExtensions.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.FileProviders; +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.Extensions.PushNotifications; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for . +/// +public static class FirebaseNotifierOptionsExtensions +{ + /// + /// Configures the application to use a specified private key to generate a token for the notifier. + /// + /// The Firebase notification options to configure. + /// The path for the Service Account JSON file. + /// + public static FirebaseNotifierOptions UseConfigurationFromFile(this FirebaseNotifierOptions options, string path) + => options.UseConfigurationFromFile(new FileInfo(path)); + + /// + /// Configures the application to use a specified private key to generate a token for the notifier. + /// + /// The Firebase notification options to configure. + /// The pointing to the Service Account JSON file. + /// + public static FirebaseNotifierOptions UseConfigurationFromFile(this FirebaseNotifierOptions options, FileInfo fileInfo) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + if (fileInfo == null) throw new ArgumentNullException(nameof(fileInfo)); + + using var stream = fileInfo.OpenRead(); + return options.UseConfigurationFromStream(stream); + } + + /// + /// Configures the application to use a specified private key to generate a token for the notifier. + /// + /// The Firebase notification options to configure. + /// The pointing to the Service Account JSON file. + /// + public static FirebaseNotifierOptions UseConfigurationFromFile(this FirebaseNotifierOptions options, IFileInfo fileInfo) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + if (fileInfo == null) throw new ArgumentNullException(nameof(fileInfo)); + + using var stream = fileInfo.CreateReadStream(); + return options.UseConfigurationFromStream(stream); + } + + /// + /// Configures the application to use a specified private key to generate a token for the notifier. + /// + /// The Firebase notification options to configure. + /// The containing the Service Account JSON configuration. + /// + public static FirebaseNotifierOptions UseConfigurationFromStream(this FirebaseNotifierOptions options, Stream stream) + { + // parse the stream into settings using JSON + var settings = JsonSerializer.Deserialize(stream, PushNotificationsJsonSerializerContext.Default.FirebaseSettings) + ?? throw new InvalidOperationException("The provided stream does not contain a valid JSON object"); + + // Ensure the configuration file is for a Service Account + if (settings.Type != "service_account") + { + throw new InvalidOperationException("Only Service Accounts are supported."); + } + + // set values in the options + options.ProjectId = settings.ProjectId; + options.ClientEmail = settings.ClientEmail; + options.TokenUri = settings.TokenUri; + options.PrivateKey = settings.PrivateKey; + + return options; + } +} + +internal sealed record FirebaseSettings +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("project_id")] + public string? ProjectId { get; set; } + + [JsonPropertyName("client_email")] + public string? ClientEmail { get; set; } + + [JsonPropertyName("token_uri")] + public string? TokenUri { get; set; } + + [JsonPropertyName("private_key")] + public string? PrivateKey { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseErrorCode.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseErrorCode.cs new file mode 100644 index 0000000..1979a8f --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseErrorCode.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +/// +/// Represents a reason why an FCM request failed. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FirebaseErrorCode +{ + /// + /// Internal server error. + /// + INTERNAL, + + /// + /// One or more arguments specified in the request were invalid. + /// + INVALID_ARGUMENT, + + /// + /// Sending limit exceeded for the message target. + /// + QUOTA_EXCEEDED, + + /// + /// The authenticated sender ID is different from the sender ID for the registration token. + /// + SENDER_ID_MISMATCH, + + /// + /// APNs certificate or web push auth key was invalid or missing. + /// + THIRD_PARTY_AUTH_ERROR, + + /// + /// Cloud Messaging service is temporarily unavailable. + /// + UNAVAILABLE, + + /// + /// App instance was unregistered from FCM. + /// This usually means that the token used is no longer valid and a new one must be used. + /// + UNREGISTERED, +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroid.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroid.cs new file mode 100644 index 0000000..b64cdf0 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroid.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageAndroid +{ + /// + /// An identifier of a group of messages that can be collapsed, + /// so that only the last message gets sent when delivery can be resumed. + /// A maximum of 4 different collapse keys is allowed at any given time. + /// + [JsonPropertyName("collapse_key")] + public string? CollapseKey { get; set; } + + /// + /// Message priority. Can take "normal" and "high" values. + /// For more information, see Setting the priority of a message. + /// + [JsonPropertyName("priority")] + public FirebaseMessageAndroidPriority? Priority { get; set; } + + /// + /// How long (in seconds) the message should be kept in FCM storage if the device is offline. + /// The maximum time to live supported is 4 weeks, and the default value is 4 weeks if not set. + /// Set it to 0 if want to send the message immediately. + /// In JSON format, the Duration type is encoded as a string rather than an object, + /// where the string ends in the suffix "s" (indicating seconds) and is preceded by the number of seconds, + /// with nanoseconds expressed as fractional seconds. + ///
+ /// For example, 3 seconds with 0 nanoseconds should be encoded in JSON format as "3s", + /// while 3 seconds and 1 nanosecond should be expressed in JSON format as "3.000000001s". + /// The ttl will be rounded down to the nearest second. + ///
+ /// A duration in seconds with up to nine fractional digits, ending with 's'. Example: "3.5s". + ///
+ [JsonPropertyName("ttl")] + public string? Ttl { get; set; } + + /// + /// Package name of the application where the registration token must match in order to receive the message. + /// + [JsonPropertyName("restricted_package_name")] + public string? RestrictedPackageName { get; set; } + + /// + /// Arbitrary key/value payload. If present, it will override + /// + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } + + /// + /// Notification to send to android devices. + /// + [JsonPropertyName("notification")] + public FirebaseMessageAndroidNotification? Notification { get; set; } + + /// + /// Options for features provided by the FCM SDK for Android. + /// + [JsonPropertyName("fcm_options")] + public FirebaseMessageAndroidFcmOptions? FcmOptions { get; set; } + + /// + /// If set to true, messages will be allowed to be delivered to the app while the device is in direct boot mode. + /// See Support Direct Boot mode. + /// + [JsonPropertyName("direct_boot_ok")] + public string? DirectBootOk { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidColor.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidColor.cs new file mode 100644 index 0000000..ec9a6f5 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidColor.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// The amount of red in the color. +/// The amount of green in the color. +/// The amount of blue in the color. +/// The fraction of this color that should be applied to the pixel. +public record struct FirebaseMessageAndroidColor([property: JsonPropertyName("red")] float Red, + [property: JsonPropertyName("green")] float Green, + [property: JsonPropertyName("blue")] float Blue, + [property: JsonPropertyName("alpha")] float? Alpha = null); diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidFcmOptions.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidFcmOptions.cs new file mode 100644 index 0000000..91ac8ae --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidFcmOptions.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageAndroidFcmOptions +{ + /// + /// Label associated with the message's analytics data. + /// + [JsonPropertyName("analytics_label")] + public string? AnalyticsLabel { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidLightSettings.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidLightSettings.cs new file mode 100644 index 0000000..a222514 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidLightSettings.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// Set color of the LED. +/// Along with , define the blink rate of LED flashes. +/// Resolution defined by proto.Duration +///
+/// A duration in seconds with up to nine fractional digits, ending with 's'. Example: "3.5s". +/// Along with , define the blink rate of LED flashes. +/// Resolution defined by proto.Duration +///
+/// A duration in seconds with up to nine fractional digits, ending with 's'. Example: "3.5s". /// +public record FirebaseMessageAndroidLightSettings([property: JsonPropertyName("color")] FirebaseMessageAndroidColor Color, + [property: JsonPropertyName("light_on_duration")] string LightOnDuration, + [property: JsonPropertyName("light_off_duration")] string LightOffDuration); diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotification.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotification.cs new file mode 100644 index 0000000..c672735 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotification.cs @@ -0,0 +1,227 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageAndroidNotification +{ + /// + /// The notification's title. + /// If present, it will override + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The notification's body text. + /// If present, it will override + /// + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// + /// The notification's icon. + /// Sets the notification icon to myicon for drawable resource myicon. + /// If you don't send this key in the request, FCM displays the launcher icon specified in your app manifest. + /// + [JsonPropertyName("icon")] + public string? Icon { get; set; } + + /// + /// The notification's icon color, expressed in #rrggbb format. + /// + [JsonPropertyName("color")] + public string? Color { get; set; } + + /// + /// The sound to play when the device receives the notification. + /// Supports default or the filename of a sound resource bundled in the app. + /// Sound files must reside in /res/raw/ folder. + /// + [JsonPropertyName("sound")] + public string? Sound { get; set; } + + /// + /// Identifier used to replace existing notifications in the notification drawer. + /// If not specified, each request creates a new notification. + /// If specified and a notification with the same tag is already being shown, + /// the new notification replaces the existing one in the notification drawer. + /// + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + /// + /// The action associated with a user click on the notification. + /// If specified, an activity with a matching intent filter is launched when a user clicks on the notification. + /// + [JsonPropertyName("click_action")] + public string? ClickAction { get; set; } + + /// + /// The key to the body string in the app's string resources to use to localize the body text to the user's current localization. + /// + /// + /// See String Resources for more information. + /// + [JsonPropertyName("body_loc_key")] + public string? BodyLocalizationKey { get; set; } + + /// + /// Variable string values to be used in place of the format specifiers in + /// to use to localize the body text to the user's current localization. + /// + /// + /// See Formatting and Styling + /// for more information. + /// + [JsonPropertyName("body_loc_args")] + public ICollection? BodyLocalizationArgs { get; set; } + + /// + /// The key to the title string in the app's string resources to use to localize the title text to the user's current localization. + /// + /// + /// See String Resources for more information. + /// + [JsonPropertyName("title_loc_key")] + public string? TitleLocalizationKey { get; set; } + + /// + /// Variable string values to be used in place of the format specifiers in + /// to use to localize the title text to the user's current localization. + /// + /// + /// See Formatting and Styling + /// for more information. + /// + [JsonPropertyName("title_loc_args")] + public ICollection? TitleLocalizationArgs { get; set; } + + /// + /// The notification's channel id (new in Android O). + ///
+ /// The app must create a channel with this channel ID before any notification with this channel ID is received. + ///
+ /// If you don't send this channel ID in the request, or if the channel ID provided has not yet been created by the app, + /// FCM uses the channel ID specified in the app manifest. + ///
+ [JsonPropertyName("channel_id")] + public string? ChannelId { get; set; } + + /// + /// Sets the "ticker" text, which is sent to accessibility services. + /// Prior to API level 21 (Lollipop), sets the text that is displayed in the status bar when the notification first arrives. + /// + [JsonPropertyName("ticker")] + public string? Ticker { get; set; } + + /// + /// When set to false or unset, the notification is automatically dismissed when the user clicks it in the panel + /// When set to true, the notification persists even when the user clicks it. + /// + [JsonPropertyName("sticky")] + public bool? Sticky { get; set; } + + /// + /// Set the time that the event in the notification occurred. + /// Notifications in the panel are sorted by this time. + /// + [JsonPropertyName("event_time")] + public DateTimeOffset? EventTime { get; set; } + + /// + /// Set whether or not this notification is relevant only to the current device. + /// Some notifications can be bridged to other devices for remote display, such as a Wear OS watch. + /// This hint can be set to recommend this notification not be bridged. + /// See Wear OS guides + /// + [JsonPropertyName("local_only")] + public bool? LocalOnly { get; set; } + + /// + /// Set the relative priority for this notification. + /// Priority is an indication of how much of the user's attention should be consumed by this notification. + /// Low-priority notifications may be hidden from the user in certain situations, while the user might be interrupted for a higher-priority notification. + /// The effect of setting the same priorities may differ slightly on different platforms. + /// Note this priority differs from . + /// This priority is processed by the client after the message has been delivered, + /// whereas AndroidMessagePriority + /// is an FCM concept that controls when the message is delivered. + /// + [JsonPropertyName("notification_priority")] + public FirebaseMessageAndroidNotificationPriority? NotificationPriority { get; set; } + + /// + /// If set to true, use the Android framework's default sound for the notification. + /// Default values are specified in + /// config.xml. + /// + [JsonPropertyName("default_sound")] + public bool? DefaultSound { get; set; } + + /// + /// If set to true, use the Android framework's default vibrate pattern for the notification. + /// Default values are specified in + /// config.xml. + /// If is set to true and is also set, + /// the default value is used instead of the user-specified . + /// + [JsonPropertyName("default_vibrate_timings")] + public bool? DefaultVibrateTimings { get; set; } + + /// + /// If set to true, use the Android framework's default LED light settings for the notification. + /// Default values are specified in + /// config.xml. + /// If is set to true and is also set, + /// the user-specified is used instead of the default value. + /// + [JsonPropertyName("default_light_settings")] + public bool? DefaultLightSettings { get; set; } + + /// + /// Set the vibration pattern to use. + /// Pass in an array of protobuf.Duration to turn on or off the vibrator. + /// The first value indicates the Duration to wait before turning the vibrator on. + /// The next value indicates the Duration to keep the vibrator on. + /// Subsequent values alternate between Duration to turn the vibrator off and to turn the vibrator on. + /// If is set and is set to true, + /// the default value is used instead of the user-specified . + ///
+ /// A duration in seconds with up to nine fractional digits, ending with 's'. Example: "3.5s". + ///
+ [JsonPropertyName("vibrate_timings")] + public ICollection? VibrateTimings { get; set; } + + /// + /// Set the notification visibility of the notification. + /// + [JsonPropertyName("visibility")] + public FirebaseMessageAndroidVisibility? Visibility { get; set; } + + /// + /// Sets the number of items this notification represents. + /// May be displayed as a badge count for launchers that support badging. + /// See Notification Badge. + /// For example, this might be useful if you're using just one notification to represent multiple new messages + /// but you want the count here to represent the number of total new messages. + /// If zero or unspecified, systems that support badging use the default, which is to increment a number displayed + /// on the long-press menu each time a new notification arrives. + /// + [JsonPropertyName("notification_count")] + public int? NotificationCount { get; set; } + + /// + /// Settings to control the notification's LED blinking rate and color if LED is available on the device. + /// The total blinking time is controlled by the OS. + /// + [JsonPropertyName("light_settings")] + public FirebaseMessageAndroidLightSettings? LightSettings { get; set; } + + /// + /// Contains the URL of an image that is going to be displayed in a notification. + /// If present, it will override + /// + [JsonPropertyName("image")] + public string? Image { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotificationPriority.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotificationPriority.cs new file mode 100644 index 0000000..073594c --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidNotificationPriority.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FirebaseMessageAndroidNotificationPriority +{ + /// + /// If priority is unspecified, notification priority is set to . + /// + PRIORITY_UNSPECIFIED, + + /// + /// Lowest notification priority. + /// Notifications with this priority might not be shown to the user except under special circumstances, such as detailed notification logs. + /// + PRIORITY_MIN, + + /// + /// Lower notification priority. + /// The UI may choose to show the notifications smaller, or at a different position in the list, compared with notifications with . + /// + PRIORITY_LOW, + + /// + /// Default notification priority. + /// If the application does not prioritize its own notifications, use this value for all notifications. + /// + PRIORITY_DEFAULT, + + /// + /// Higher notification priority. + /// Use this for more important notifications or alerts. + /// The UI may choose to show these notifications larger, or at a different position in the notification lists, compared with notifications with . + /// + PRIORITY_HIGH, + + /// + /// Highest notification priority. + /// Use this for the application's most important items that require the user's prompt attention or input. + /// + PRIORITY_MAX, +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidPriority.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidPriority.cs new file mode 100644 index 0000000..853008d --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidPriority.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FirebaseMessageAndroidPriority +{ + /// + /// Default priority for data messages. + /// Normal priority messages won't open network connections on a sleeping device, and their delivery may be delayed to conserve the battery. + /// For less time-sensitive messages, such as notifications of new email or other data to sync, choose normal delivery priority. + /// + NORMAL, + + /// + /// Default priority for notification messages. + /// FCM attempts to deliver high priority messages immediately, allowing the FCM service to wake + /// a sleeping device when possible and open a network connection to your app server. + /// Apps with instant messaging, chat, or voice call alerts, for example, generally need to open + /// a network connection and make sure FCM delivers the message to the device without delay. + /// Set high priority if the message is time-critical and requires the user's immediate interaction, + /// but beware that setting your messages to high priority contributes more to battery drain compared with normal priority messages. + /// + HIGH, +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidVisibility.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidVisibility.cs new file mode 100644 index 0000000..d171d12 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageAndroidVisibility.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FirebaseMessageAndroidVisibility +{ + /// + /// If unspecified, default to . + /// + VISIBILITY_UNSPECIFIED, + + /// + /// Show this notification on all lockscreens, but conceal sensitive or private information on secure lockscreens. + /// + PRIVATE, + + /// + /// Show this notification in its entirety on all lockscreens. + /// + PUBLIC, + + /// + /// Do not reveal any part of this notification on a secure lockscreen. + /// + SECRET, +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApns.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApns.cs new file mode 100644 index 0000000..6dec70d --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApns.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageApns +{ + /// + /// HTTP request headers defined in Apple Push Notification Service. + /// Refer to APNs request headers + /// for supported headers such as apns-expiration and apns-priority. + ///
+ /// The backend sets a default value for apns-expiration of 30 days and a default value for apns-priority of 10 if not explicitly set. + ///
+ [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + /// + /// APNs payload, including both aps dictionary and custom payload. + /// Payload Key Reference. + /// If present, "title" and "body" fields override and . + /// + [JsonPropertyName("payload")] + public JsonObject? Payload { get; set; } + + /// + /// Options for features provided by the FCM SDK for iOS. + /// + [JsonPropertyName("fcm_options")] + public FirebaseMessageApnsFcmOptions? FcmOptions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApnsFcmOptions.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApnsFcmOptions.cs new file mode 100644 index 0000000..4605770 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageApnsFcmOptions.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageApnsFcmOptions +{ + /// + /// Label associated with the message's analytics data. + /// + [JsonPropertyName("analytics_label")] + public string? AnalyticsLabel { get; set; } + + /// + /// Contains the URL of an image that is going to be displayed in a notification. + /// If present, it will override + /// + [JsonPropertyName("image")] + public string? Image { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageFcmOptions.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageFcmOptions.cs new file mode 100644 index 0000000..cd4a9d6 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageFcmOptions.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageFcmOptions +{ + /// + /// Label associated with the message's analytics data. + /// + [JsonPropertyName("analytics_label")] + public string? AnalyticsLabel { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpush.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpush.cs new file mode 100644 index 0000000..9cb4e31 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpush.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageWebpush +{ + /// + /// HTTP headers defined in webpush protocol. + /// Refer to Webpush protocol for supported headers, + /// e.g. "TTL": "15". + /// + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + /// + /// Arbitrary key/value payload. If present, it will override + /// + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } + + /// + /// Web Notification options. + /// Supports Notification instance properties as defined in + /// Web Notification API. + /// If present, "title" and "body" fields override and . + /// + [JsonPropertyName("notification")] + public JsonObject? Notification { get; set; } + + /// + /// Options for features provided by the FCM SDK for Web. + /// + [JsonPropertyName("fcm_options")] + public FirebaseMessageWebpushFcmOptions? FcmOptions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpushFcmOptions.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpushFcmOptions.cs new file mode 100644 index 0000000..e5677c8 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseMessageWebpushFcmOptions.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseMessageWebpushFcmOptions +{ + /// + /// The link to open when the user clicks on the notification. For all URL values, HTTPS is required. + /// + [JsonPropertyName("link")] + public string? Link { get; set; } + + /// + /// Label associated with the message's analytics data. + /// + [JsonPropertyName("analytics_label")] + public string? AnalyticsLabel { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseNotification.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseNotification.cs new file mode 100644 index 0000000..827a607 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseNotification.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseNotification +{ + /// + /// The notification's title. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The notification's body text. + /// + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// + /// Contains the URL of an image that is going to be downloaded on the device and displayed in a notification. + /// JPEG, PNG, BMP have full support across platforms. + /// Animated GIF and video only work on iOS. + /// WebP and HEIF have varying levels of support across platforms and platform versions. + /// Android has 1MB image size limit. + /// Quota usage and implications/costs for hosting image on Firebase Storage: https://firebase.google.com/pricing + /// + [JsonPropertyName("image")] + public string? Image { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequest.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequest.cs new file mode 100644 index 0000000..2fdf377 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequest.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +/// Represents a request payload sent to Firebase Cloud Messaging (FCM) +/// +public class FirebaseRequest +{ + /// + /// + /// + /// Message to send. + /// Flag for testing the request without actually delivering the message. + public FirebaseRequest(FirebaseRequestMessage message, bool? validateOnly = null) + { + Message = message ?? throw new ArgumentNullException(nameof(message)); + ValidateOnly = validateOnly; + } + + /// + /// Message to send. + /// + [JsonPropertyName("message")] + public FirebaseRequestMessage Message { get; set; } + + /// + /// Flag for testing the request without actually delivering the message. + /// + [JsonPropertyName("validate_only")] + public bool? ValidateOnly { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequestMessage.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequestMessage.cs new file mode 100644 index 0000000..23f0f6a --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseRequestMessage.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +public class FirebaseRequestMessage +{ + /// + /// Arbitrary key/value payload, which must be UTF-8 encoded. + /// The key should not be a reserved word (from, message_type, or any word starting with google or gcm). + /// When sending payloads containing only data fields to iOS devices, only normal priority ("apns-priority": "5") is allowed in ApnsConfig. + /// + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } + + /// + /// Basic notification template to use across all platforms. + /// + [JsonPropertyName("notification")] + public FirebaseNotification? Notification { get; set; } + + /// + /// Android specific options for messages sent through FCM connection server. + /// + [JsonPropertyName("android")] + public FirebaseMessageAndroid? Android { get; set; } + + /// + /// Webpush options. + /// + [JsonPropertyName("webpush")] + public FirebaseMessageWebpush? Webpush { get; set; } + + /// + /// Apple Push Notification Service options. + /// + [JsonPropertyName("apns")] + public FirebaseMessageApns? Apns { get; set; } + + /// + /// Template for FCM SDK feature options to use across all platforms. + /// + [JsonPropertyName("fcm_options")] + public FirebaseMessageFcmOptions? FcmOptions { get; set; } + + /// + /// Registration token to send a message to. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Topic name to send a message to, e.g. weather. Note: /topics/ prefix should not be provided. + /// + [JsonPropertyName("topic")] + public string? Topic { get; set; } + + /// + /// Condition to send a message to, e.g. 'foo' in topics && 'bar' in topics. + /// + [JsonPropertyName("condition")] + public string? Condition { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponse.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponse.cs new file mode 100644 index 0000000..eb38782 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +/// Represents a response payload received from Firebase Cloud Messaging (FCM). +/// +public class FirebaseResponse +{ + /// + /// The identifier of the message sent, in the format of projects/*/messages/{message_id}. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponseProblem.cs b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponseProblem.cs new file mode 100644 index 0000000..380000d --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Firebase/Models/FirebaseResponseProblem.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; +using Tingle.Extensions.PushNotifications.FcmLegacy.Models; + +namespace Tingle.Extensions.PushNotifications.Firebase.Models; + +/// +/// Represents a problem response from Firebase +/// +public class FirebaseResponseProblem +{ + /// + [JsonPropertyName("error")] + public FirebaseResponseError? Error { get; set; } + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} + +/// +public class FirebaseResponseError +{ + /// + [JsonPropertyName("code")] + public int Code { get; set; } + + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + [JsonPropertyName("details")] + public List? Details { get; set; } = new(); + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} + +/// +public class FirebaseResponseErrorDetails +{ + /// + [JsonPropertyName("@type")] + public string? Type { get; set; } + + /// + [JsonPropertyName("errorCode")] + public FirebaseErrorCode ErrorCode { get; set; } + + [JsonExtensionData] + internal IDictionary? Extensions { get; set; } +} diff --git a/src/Tingle.Extensions.PushNotifications/IServiceCollectionExtensions.cs b/src/Tingle.Extensions.PushNotifications/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..a60dc75 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/IServiceCollectionExtensions.cs @@ -0,0 +1,93 @@ +using Tingle.Extensions.Http; +using Tingle.Extensions.PushNotifications.Apple; +using Tingle.Extensions.PushNotifications.FcmLegacy; +using Tingle.Extensions.PushNotifications.Firebase; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for related to push notifications +/// +public static class IServiceCollectionExtensions +{ + /// + /// Add notification services for Firebase Cloud Messaging (FCM) using the legacy HTTP API. + /// + /// the services collection in which to register the services + /// action to configure options + /// + public static IHttpClientBuilder AddFcmLegacyNotifier(this IServiceCollection services, + Action? configure = null) + { + // configure authentication + services.AddTransient(); + services.ConfigureOptions(); + + var builder = services.AddNotifier(configure) + .AddAuthenticationHandler(); + + return builder; + } + + /// + /// Add notification services for Firebase Cloud Messaging (FCM). + /// + /// the services collection in which to register the services + /// action to configure options + /// + public static IHttpClientBuilder AddFirebaseNotifier(this IServiceCollection services, + Action? configure = null) + { + // configure authentication + services.AddTransient(); + services.ConfigureOptions(); + + var builder = services.AddNotifier(configure) + .AddAuthenticationHandler(); + + return builder; + } + + /// + /// Add notification services for Apple Push Notification Service (APNS) + /// + /// the services collection in which to register the services + /// action to configure options + /// + public static IHttpClientBuilder AddApnsNotifier(this IServiceCollection services, + Action? configure = null) + { + // configure authentication + services.AddTransient(); + services.ConfigureOptions(); + + var builder = services.AddNotifier(configure) + .AddAuthenticationHandler(); + + // APNS requires TLS 1.2 + builder.ConfigurePrimaryHttpMessageHandler(() => + { + return new HttpClientHandler + { + SslProtocols = System.Security.Authentication.SslProtocols.Tls12 + }; + }); + + return builder; + } + + private static IHttpClientBuilder AddNotifier(this IServiceCollection services, Action? configure = null) + + where TNotifier : AbstractHttpApiClient + where TOptions : AbstractHttpApiClientOptions, new() + { + return services.AddHttpApiClient(options => + { + // include error details in the exception + options.IncludeHeadersInExceptionMessage = true; + options.IncludeRawBodyInExceptionMessage = true; + + configure?.Invoke(options); + }); + } +} diff --git a/src/Tingle.Extensions.PushNotifications/PushNotificationsJsonSerializerContext.cs b/src/Tingle.Extensions.PushNotifications/PushNotificationsJsonSerializerContext.cs new file mode 100644 index 0000000..1022f2c --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/PushNotificationsJsonSerializerContext.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.PushNotifications; + +[JsonSerializable(typeof(Apple.Models.ApnsMessageData))] +[JsonSerializable(typeof(Apple.Models.ApnsMessageResponse))] +[JsonSerializable(typeof(Apple.Models.ApnsResponseError))] +[JsonSerializable(typeof(Apple.ApnsAuthenticationHandler.ApnsAuthHeader))] +[JsonSerializable(typeof(Apple.ApnsAuthenticationHandler.ApnsAuthPayload))] + +[JsonSerializable(typeof(FcmLegacy.Models.FcmLegacyRequest))] +[JsonSerializable(typeof(FcmLegacy.Models.FcmLegacyResponse))] + +[JsonSerializable(typeof(Firebase.Models.FirebaseRequest))] +[JsonSerializable(typeof(Firebase.Models.FirebaseResponse))] +[JsonSerializable(typeof(Firebase.Models.FirebaseResponseProblem))] +[JsonSerializable(typeof(Firebase.FirebaseAuthenticationHandler.FirebaseAuthHeader))] +[JsonSerializable(typeof(Firebase.FirebaseAuthenticationHandler.FirebaseAuthPayload))] + +[JsonSerializable(typeof(Microsoft.Extensions.DependencyInjection.FirebaseSettings))] +internal partial class PushNotificationsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Tingle.Extensions.PushNotifications/README.md b/src/Tingle.Extensions.PushNotifications/README.md new file mode 100644 index 0000000..0f2c1fd --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/README.md @@ -0,0 +1,164 @@ + +# Tingle.Extensions.PushNotifications + +Push notifications are messages that pop up in a mobile device. Mainly users see a notification as a banner or a pop-up alert as they are using their phone. They can be scheduled by app publishers to be sent at a specific time. Users do not have to be in the app or to be using their devices to receive them. The push notifications can be targeted to segments of the user base, and even personalized for specific app users which is a major advantage compared to SMS text messaging. However, they also require the management of user identification data. + +The actors in sending push notifications include: +**Operating System Push Notification Service(OSPNS)**: Each mobile platform has support for push notifications. Google uses Firebase Cloud Messaging to send push notifications while for iOS Apple Notification service is used. + +**App publisher**: The app publisher enables their app with an Operating System Push Notification Service. Then the publisher uploads the app to the app store. + +**Client app**:This is an OS specific app installed on a user's device. It receives incoming notifications. + +`Tingle.Extensions.Notifications` is a customized library developed by Tingle Software which comprises of push notification providers for both Apple and Google's Firebase. A provider is a server that is deployed and managed to work with the notification services. + +## Apple Push Notification Service (APNS) + +Apple Push Notification Service(APNs) is a service that allows your device to be constantly connected to Apple's push notification server. + +When you want to send a push notification to an application installed on the users' devices, you(the provider) can contact the APNs so that it can deliver a push message to the particular application installed on the intended device. + +iOS lets users customize push notifications at an individual app level. Users can turn sounds on or off, and pick the style that iOS uses to show a notification. Users can also control the red “badge” showing the number of unread notifications on an app’s homescreen icon. + +To securely connect the APNs, you can use provider authentication tokens or provider certificates. This library has been customized to use tokens for secure connection. The provider API supports the JWT specification letting you pass statements and metadata, referred to as claims, to APNs along with each push notification. + +The provider authentication token is a JSON object that is constructed whose header must include: + +- The encryption algorithm(alg) used to encrypt the token. +- A 10-character key identifier(kid) key obtained from the developer account. + +The payload of the token in this library includes: + +- The issuer(iss) registered claim key, whose value is your 10-character team ID obtained from your developer account. +- The issued at(iat) registered claim key, whose value indicates the time at which the token was generated, in terms of number of seconds, since Epoch, in UTC. + +After the token is created it must be signed with a private key. The token is encrypted using the Elliptic Curve Digital Signature Algorithm(ECDSA) with the SHA 256 hash algorithm. + +The library contains the configuration options for establishing a connection with the appropriate APNs server,that is, the development server whose URL is api.development.push.apple.com:443 and the production server whose URL is api.push.apple.com:443. + +### Configuration + +```json +{ + "Apns:AppBundleIdentifier": "my_app_bundle_identifier", + "Apns:PrivateKey": "gw3XzpoY9kWsKyca74ReCg==", + "Apns:PrivateKeyId": "gw3XzpoY9kWsKyca74ReCg==", + "Apns:TeamId": "gw3XzpoY9kWsKyca74ReCg==", + // .... +} +``` + +### Adding to Services Collection + +In `Program.cs` add the following code snippet: + +```cs +// Add Apple Notification services +builder.Services.AddApnsNotifier(Configuration.GetSection("Apns")); + +// The sample service we'll use to demonstrate usage +builder.Services.AddScoped(); +// .... +``` + +The APNs message format in the library has the following parameters: + +- unique identifier for the message +- token for the device to send message to +- absolute expiry time in seconds +- priority of message +- actual data sent to the device + +### Usage In A Sample Service +In `NotificationManager.cs` add the following code snippet: + +```cs +private readonly ApnsNotifier apnsNotifier; +public NotificationManager(ApnsNotifier apnsNotifier) +{ + this.apnsNotifier = apnsNotifier; +} + +async Task SendApnsAsync(CancellationToken cancellationToken = default) +{ + // prepare the aps section/node + // According to Listing 7-1 Configuring a background update notification, + // we should set content-available = 1 if the other properties of aps are not set. + // https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html + var aps = new ApnsMessagePayload { ContentAvailable = 1 }; + + await apnsNotifier.SendAsync(token: "provide_token_here", + payload: aps, + action: "provide_action_here", + extras: new {}, // provide any extras here + collapseId: "provide_key_here", // provide this if you'd like multiple notifications with same identifier to appear as one to the user + cancellationToken: cancellationToken); +} + +``` + +## Firebase (FCM) + +Google uses Firebase Cloud Messaging(FCM) to send notifications to apps. FCM is a free cross platform messaging solution that lets you send push notifications to your audience, without having to worry about the server code. Since FCM is one of Firebase services, it is required for the app to be registered with Firebase. An API key is then provided after registration. + +Whenever a user downloads the application, Firebase issues a unique ID to the app-device combination so as to enable push notifications from Firebase. After the app-device combination has been registered, an app server identification is also required. It enables the app server to send notifications to the user's device on behalf of the app. A server ID is created using API keys provided by Firebase. After both of these registrations have been done it is possible to send push notifications. + +Unlike the Apple Notification Service where the notification message can be configured, for Firebase the notification messages are handled by the Firebase SDK. + +When using the legacy HTTP protocol, the app server should direct all requests to the endpoint: https://fcm.googleapis.com/fcm/send. + +The push request as modelled in the library is a JSON formatted string with the following properties: +`registration_ids`: An array of registration tokens to which to send a multicast message (a message sent to more than one registration token). It can be ignored if to is used. +`collapse_key`: Used to avoid sending too many of the same messages when the device comes back online or becomes active. +`to`: The particular registration token to be targeted. It can be ognored if registration_ids is used. +`data`: The data to be sent to the device. + +### Configuration + +```json +{ + "Firebase:ProjectId": "my_project_id_here", + "Firebase:Key": "my_key_here", +.... +} +``` + +### Adding to Services Collection + +In `Program.cs` add the following code snippet: + +```cs +// Add Fcm notification services +builder.Services.AddFirebaseNotifier(Configuration.GetSection("Firebase")); + +// The sample service we'll use to demonstrate usage +builder.Services.AddScoped(); + +// ... +``` + +### Usage In A Sample Service + +In `NotificationManager.cs` add the following code snippet: + +```cs +private readonly FirebasePushNotifier firebaseNotifier; +public NotificationManager(FirebasePushNotifier firebaseNotifier) +{ + this.firebaseNotifier = firebaseNotifier; +} + +async Task SendFirebaseAsync(CancellationToken cancellationToken = default) +{ + var message = new FirebasePushRequestModel + { + RegistrationIds = ["","",...], // provide tokens here + Data = new StandardNotificationData { Action = "my_action_here", Extras = new {} // my extras here if any }, + CollapseKey = collapseKey, // provide this if you'd like multiple notifications with same identifier to appear as one to the user + }; + + // send the push notification + await firebaseNotifier.SendAsync(message, cancellationToken); +} + +``` diff --git a/src/Tingle.Extensions.PushNotifications/ResourceResponseExtensions.cs b/src/Tingle.Extensions.PushNotifications/ResourceResponseExtensions.cs new file mode 100644 index 0000000..00404d7 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/ResourceResponseExtensions.cs @@ -0,0 +1,20 @@ +using Tingle.Extensions.Http; + +namespace Tingle.Extensions.PushNotifications; + +/// +/// Extensions for +/// +public static class ResourceResponseExtensions +{ + /// + /// Get the request id from the response headers. + /// + /// + /// + public static string? GetApnsId(this ResourceResponseHeaders headers) + { + if (headers is null) throw new ArgumentNullException(nameof(headers)); + return headers.TryGetValue("apns-id", out var value) ? value.Single() : null; + } +} diff --git a/src/Tingle.Extensions.PushNotifications/Tingle.Extensions.PushNotifications.csproj b/src/Tingle.Extensions.PushNotifications/Tingle.Extensions.PushNotifications.csproj new file mode 100644 index 0000000..dc292e3 --- /dev/null +++ b/src/Tingle.Extensions.PushNotifications/Tingle.Extensions.PushNotifications.csproj @@ -0,0 +1,21 @@ + + + + net7.0 + Basics for push notifications via FCM, APNS etc + + + + + + + + + + + + + + + + diff --git a/tests/Tingle.Extensions.PushNotifications.Tests/ApnsNotifierTests.cs b/tests/Tingle.Extensions.PushNotifications.Tests/ApnsNotifierTests.cs new file mode 100644 index 0000000..3979121 --- /dev/null +++ b/tests/Tingle.Extensions.PushNotifications.Tests/ApnsNotifierTests.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; +using Tingle.Extensions.PushNotifications.Apple; +using Tingle.Extensions.PushNotifications.Apple.Models; +using Xunit.Abstractions; + +namespace Tingle.Extensions.PushNotifications.Tests; + +public class ApnsNotifierTests +{ + private readonly ITestOutputHelper outputHelper; + + public ApnsNotifierTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper ?? throw new ArgumentNullException(nameof(outputHelper)); + } + + [Fact] + public void Resolution_Works() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddMemoryCache(); + services.AddApnsNotifier(o => + { + o.BundleId = "cake"; + o.PrivateKeyBytes = (keyId) => Task.FromResult(Encoding.UTF8.GetBytes("cake")); + o.KeyId = "cake"; + o.TeamId = "cake"; + }); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + } + + [Fact] + public async Task Authentication_IsPopulated() + { + var header = (string?)null; + var handler = new DynamicHttpMessageHandler((request, ct) => + { + Interlocked.Exchange(ref header, request.Headers.Authorization?.ToString()); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + }); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddMemoryCache(); + services.AddApnsNotifier(options => + { + options.BundleId = "cake"; + options.PrivateKeyBytes = (keyId) => Task.FromResult(Encoding.UTF8.GetBytes("cake")); + options.KeyId = "cake"; + options.TeamId = "cake"; + }).ConfigurePrimaryHttpMessageHandler(() => handler); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var cache = sp.GetRequiredService(); + cache.Set("apns:tokens:cake:cake", "cake-token"); + var client = sp.GetRequiredService(); + + var rr = await client.SendAsync(new ApnsMessageHeader { DeviceToken = "cake" }, new ApnsMessageData { }); + Assert.Equal("bearer cake-token", header); + } + + [Fact] + public void ParsePrivateKey_Works() + { + using var ecdsa = ECDsa.Create(); + var key = ecdsa.ExportPkcs8PrivateKey(); + + // bas64 only + var parsed = ApnsNotifierOptionsExtensions.ParsePrivateKey(Convert.ToBase64String(key)); + Assert.Equal(key, parsed); // sequence equal + + // base64 wrapped with headers (PEM) + parsed = ApnsNotifierOptionsExtensions.ParsePrivateKey(new string(PemEncoding.Write("PRIVATE KEY", key))); + Assert.Equal(key, parsed); // sequence equal + } + + [Fact] + public async Task Works() + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets(optional: true) // local debug + .AddEnvironmentVariables() // CI-pipeline + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddMemoryCache(); + services.AddApnsNotifier(options => + { + options.TeamId = configuration["ApnsTest:TeamId"]; + options.KeyId = configuration["ApnsTest:KeyId"]; + options.BundleId = configuration["ApnsTest:BundleId"]; + options.UsePrivateKey(keyId => configuration.GetValue("ApnsTest:PrivateKey")!); + }); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + + var header = new ApnsMessageHeader + { + DeviceToken = configuration.GetValue("ApnsTest:DeviceToken"), + Environment = configuration.GetValue("ApnsTest:Environment") ?? ApnsEnvironment.Development, + PushType = ApnsPushType.Background, + }; + var data = new ApnsMessageData + { + Aps = new ApnsMessagePayload + { + ContentAvailable = 1 + }, + }; + + var resp = await client.SendAsync(header, data, default); + resp.EnsureSuccess(); + + var id = resp.Headers.GetApnsId(); + Assert.NotNull(id); + Assert.NotEmpty(id); + } +} diff --git a/tests/Tingle.Extensions.PushNotifications.Tests/DynamicHttpMessageHandler.cs b/tests/Tingle.Extensions.PushNotifications.Tests/DynamicHttpMessageHandler.cs new file mode 100644 index 0000000..3e16756 --- /dev/null +++ b/tests/Tingle.Extensions.PushNotifications.Tests/DynamicHttpMessageHandler.cs @@ -0,0 +1,21 @@ +namespace Tingle.Extensions.PushNotifications.Tests; + +public class DynamicHttpMessageHandler : HttpMessageHandler +{ + private readonly Func> processFunc; + + public DynamicHttpMessageHandler(Func processFunc) + { + this.processFunc = (req, ct) => Task.FromResult(processFunc(req, ct)); + } + + public DynamicHttpMessageHandler(Func> processFunc) + { + this.processFunc = processFunc ?? throw new ArgumentNullException(nameof(processFunc)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return processFunc(request, cancellationToken); + } +} diff --git a/tests/Tingle.Extensions.PushNotifications.Tests/FcmLegacyNotifierTests.cs b/tests/Tingle.Extensions.PushNotifications.Tests/FcmLegacyNotifierTests.cs new file mode 100644 index 0000000..8f5e33f --- /dev/null +++ b/tests/Tingle.Extensions.PushNotifications.Tests/FcmLegacyNotifierTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Tingle.Extensions.PushNotifications.FcmLegacy; +using Tingle.Extensions.PushNotifications.FcmLegacy.Models; +using Xunit.Abstractions; + +namespace Tingle.Extensions.PushNotifications.Tests; + +public class FcmLegacyNotifierTests +{ + private readonly ITestOutputHelper outputHelper; + + public FcmLegacyNotifierTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper ?? throw new ArgumentNullException(nameof(outputHelper)); + } + + [Fact] + public void Resolution_Works() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddFcmLegacyNotifier(o => o.Key = Guid.NewGuid().ToString()); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + } + + [Fact] + public async Task Authentication_IsPopulated() + { + var header = (string?)null; + var handler = new DynamicHttpMessageHandler((request, ct) => + { + Interlocked.Exchange(ref header, Assert.Single(request.Headers.GetValues("Authorization"))); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + }); + + var key = Guid.NewGuid().ToString(); + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddFcmLegacyNotifier(options => options.Key = key) + .ConfigurePrimaryHttpMessageHandler(() => handler); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + + var model = new FcmLegacyRequest { }; + var rr = await client.SendAsync(model); + Assert.Equal($"key={key}", header); + } + + [Fact] + public async Task Works() + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets(optional: true) // local debug + .AddEnvironmentVariables() // CI-pipeline + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddFcmLegacyNotifier(options => + { + options.Key = configuration.GetValue("FcmLegacyTest:Key"); + }); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + + var model = new FcmLegacyRequest + { + RegistrationIds = new[] + { + configuration.GetValue("FcmLegacyTest:RegistrationId")!, + }, + }; + var response = await client.SendAsync(model); + response.EnsureSuccess(); + } +} diff --git a/tests/Tingle.Extensions.PushNotifications.Tests/FirebaseNotifierTests.cs b/tests/Tingle.Extensions.PushNotifications.Tests/FirebaseNotifierTests.cs new file mode 100644 index 0000000..5d81460 --- /dev/null +++ b/tests/Tingle.Extensions.PushNotifications.Tests/FirebaseNotifierTests.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Tingle.Extensions.PushNotifications.Firebase; +using Tingle.Extensions.PushNotifications.Firebase.Models; +using Xunit.Abstractions; + +namespace Tingle.Extensions.PushNotifications.Tests; + +public class FirebaseNotifierTests +{ + private readonly ITestOutputHelper outputHelper; + + public FirebaseNotifierTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper ?? throw new ArgumentNullException(nameof(outputHelper)); + } + + [Fact] + public void Resolution_Works() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddMemoryCache(); + services.AddFirebaseNotifier(options => + { + options.ProjectId = Guid.NewGuid().ToString(); + options.ClientEmail = Guid.NewGuid().ToString(); + options.TokenUri = Guid.NewGuid().ToString(); + options.PrivateKey = Guid.NewGuid().ToString(); + }); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + } + + [Fact] + public void Resolution_Work_WithConfigurationFile() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddMemoryCache(); + services.AddFirebaseNotifier(o => + { + using var rsa = RSA.Create(); + using var stream = new MemoryStream(); + System.Text.Json.JsonSerializer.Serialize(stream, new JsonObject + { + ["type"] = "service_account", + ["project_id"] = "dummy-123", + ["private_key"] = new string(PemEncoding.Write("PRIVATE KEY", rsa.ExportPkcs8PrivateKey())), + ["client_email"] = "firebase-adminsdk-12346@dummy-123.iam.gserviceaccount.com", + ["token_uri"] = "https://oauth2.googleapis.com/token", + }); + stream.Seek(0, SeekOrigin.Begin); + + o.UseConfigurationFromStream(stream); + }); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + } + + [Fact] + public async Task Authentication_IsPopulated() + { + var header = (string?)null; + var handler = new DynamicHttpMessageHandler((request, ct) => + { + if (request.RequestUri == new Uri("https://oauth2.googleapis.com/token")) + { + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = System.Net.Http.Json.JsonContent.Create(new Dictionary + { + ["access_token"] = "stupid_token", + ["expires_in"] = "3600", + }), + }; + } + + Interlocked.Exchange(ref header, Assert.Single(request.Headers.GetValues("Authorization"))); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + }); + + var key = Guid.NewGuid().ToString(); + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddMemoryCache(); + services.AddFirebaseNotifier(options => + { + using var rsa = RSA.Create(); + using var stream = new MemoryStream(); + System.Text.Json.JsonSerializer.Serialize(stream, new JsonObject + { + ["type"] = "service_account", + ["project_id"] = "dummy-12346", + ["private_key"] = new string(PemEncoding.Write("PRIVATE KEY", rsa.ExportPkcs8PrivateKey())), + ["client_email"] = "firebase-adminsdk-12346@dummy-12346.iam.gserviceaccount.com", + ["token_uri"] = "https://oauth2.googleapis.com/token", + }); + stream.Seek(0, SeekOrigin.Begin); + + options.UseConfigurationFromStream(stream); + }).ConfigurePrimaryHttpMessageHandler(() => handler); + + // override creation of FirebaseAuthenticationHandler to set the backChannel + services.AddTransient(provider => + { + return new FirebaseAuthenticationHandler( + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService>(), + new HttpClient(handler)); + }); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + + var msg = new FirebaseRequestMessage(); + var model = new FirebaseRequest(msg); + var response = await client.SendAsync(model); + response.EnsureSuccess(); + Assert.Equal("Bearer stupid_token", header); + } + + [Fact] + public async Task Works() + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets(optional: true) // local debug + .AddEnvironmentVariables() // CI-pipeline + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddMemoryCache(); + services.AddFirebaseNotifier(options => + { + options.ProjectId = configuration.GetValue("FirebaseTest:ProjectId"); + options.ClientEmail = configuration.GetValue("FirebaseTest:ClientEmail"); + options.TokenUri = configuration.GetValue("FirebaseTest:TokenUri"); + options.PrivateKey = configuration.GetValue("FirebaseTest:PrivateKey"); + options.PrivateKey = options.PrivateKey?.Replace("\\n", "\n"); // CI-pipeline + }); + + var provider = services.BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var client = sp.GetRequiredService(); + + var msg = new FirebaseRequestMessage { Token = configuration.GetValue("FirebaseTest:DeviceToken"), }; + var model = new FirebaseRequest(msg); + var response = await client.SendAsync(model); + response.EnsureSuccess(); + } +} diff --git a/tests/Tingle.Extensions.PushNotifications.Tests/Tingle.Extensions.PushNotifications.Tests.csproj b/tests/Tingle.Extensions.PushNotifications.Tests/Tingle.Extensions.PushNotifications.Tests.csproj new file mode 100644 index 0000000..0935524 --- /dev/null +++ b/tests/Tingle.Extensions.PushNotifications.Tests/Tingle.Extensions.PushNotifications.Tests.csproj @@ -0,0 +1,18 @@ + + + + 923dd109-6f0c-4b04-bcff-01618b2545ab + + + + + + + + + + + + + +