using CorePush.Interfaces;
using CorePush.Utils;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace CorePush.Apple
{
///
/// HTTP2 Apple Push Notification sender
///
public class ApnSender : IApnSender
{
private static readonly ConcurrentDictionary> tokens = new ConcurrentDictionary>();
private static readonly Dictionary servers = new Dictionary
{
{ApnServerType.Development, "https://api.development.push.apple.com:443" },
{ApnServerType.Production, "https://api.push.apple.com:443" }
};
private const string apnidHeader = "apns-id";
private const int tokenExpiresMinutes = 50;
private readonly ApnSettings settings;
private readonly HttpClient http;
///
/// Apple push notification sender constructor
///
/// Apple Push Notification settings
public ApnSender(ApnSettings settings, HttpClient http)
{
this.settings = settings ?? throw new ArgumentNullException(nameof(settings));
this.http = http ?? throw new ArgumentNullException(nameof(http));
}
///
/// Serialize and send notification to APN. Please see how your message should be formatted here:
/// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW1
/// Payload will be serialized using Newtonsoft.Json package.
/// !IMPORTANT: If you send many messages at once, make sure to retry those calls. Apple typically doesn't like
/// to receive too many requests and may ocasionally respond with HTTP 429. Just try/catch this call and retry as needed.
///
/// Throws exception when not successful
///
public ApnsResponse Send(object notification,
string deviceToken,
string apnsId = null,
int apnsExpiration = 0,
int apnsPriority = 10,
bool isBackground = false,
CancellationToken cancellationToken = default)
{
var task= SendAsync(notification, deviceToken, apnsId, apnsExpiration, apnsPriority, isBackground, cancellationToken);
task.Wait();
return task.Result;
}
public async Task SendAsync(
object notification,
string deviceToken,
string apnsId = null,
int apnsExpiration = 0,
int apnsPriority = 10,
bool isBackground = false,
CancellationToken cancellationToken = default)
{
var path = $"/3/device/{deviceToken}";
var json = JsonHelper.Serialize(notification);
var request = new HttpRequestMessage(HttpMethod.Post, new Uri(servers[settings.ServerType] + path))
{
Version = new Version(2, 0),
Content = new StringContent(json)
};
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("bearer", GetJwtToken());
request.Headers.TryAddWithoutValidation(":method", "POST");
request.Headers.TryAddWithoutValidation(":path", path);
request.Headers.Add("apns-topic", settings.AppBundleIdentifier);
request.Headers.Add("apns-expiration", apnsExpiration.ToString());
request.Headers.Add("apns-priority", apnsPriority.ToString());
request.Headers.Add("apns-push-type", isBackground ? "background" : "alert"); // for iOS 13 required
if (!string.IsNullOrWhiteSpace(apnsId))
{
request.Headers.Add(apnidHeader, apnsId);
}
using (var response = await http.SendAsync(request, cancellationToken))
{
var succeed = response.IsSuccessStatusCode;
var content = await response.Content.ReadAsStringAsync();
var error = JsonHelper.Deserialize(content);
return new ApnsResponse
{
IsSuccess = succeed,
Error = error
};
}
}
private string GetJwtToken()
{
var (token, date) = tokens.GetOrAdd(settings.AppBundleIdentifier, _ => new Tuple(CreateJwtToken(), DateTime.UtcNow));
if (date < DateTime.UtcNow.AddMinutes(-tokenExpiresMinutes))
{
tokens.TryRemove(settings.AppBundleIdentifier, out _);
return GetJwtToken();
}
return token;
}
private string CreateJwtToken()
{
var header = JsonHelper.Serialize(new { alg = "ES256", kid = CleanP8Key(settings.P8PrivateKeyId) });
var payload = JsonHelper.Serialize(new { iss = settings.TeamId, iat = ToEpoch(DateTime.UtcNow) });
var headerBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(header));
var payloadBasae64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var unsignedJwtData = $"{headerBase64}.{payloadBasae64}";
var unsignedJwtBytes = Encoding.UTF8.GetBytes(unsignedJwtData);
using (var dsa = AppleCryptoHelper.GetEllipticCurveAlgorithm(CleanP8Key(settings.P8PrivateKey)))
{
var signature = dsa.SignData(unsignedJwtBytes, 0, unsignedJwtBytes.Length, HashAlgorithmName.SHA256);
return $"{unsignedJwtData}.{Convert.ToBase64String(signature)}";
}
}
private static int ToEpoch(DateTime time)
{
var span = DateTime.UtcNow - new DateTime(1970, 1, 1);
return Convert.ToInt32(span.TotalSeconds);
}
private static string CleanP8Key(string p8Key)
{
// If we have an empty p8Key, then don't bother doing any tasks.
if (string.IsNullOrEmpty(p8Key))
{
return p8Key;
}
var lines = p8Key.Split(new [] { '\n' }).ToList();
if (0 != lines.Count && lines[0].StartsWith("-----BEGIN PRIVATE KEY-----"))
{
lines.RemoveAt(0);
}
if (0 != lines.Count && lines[lines.Count - 1].StartsWith("-----END PRIVATE KEY-----"))
{
lines.RemoveAt(lines.Count - 1);
}
var result = string.Join(string.Empty, lines);
return result;
}
}
}