namespace HrynCo.NotificationService.Worker.Services.Push; using FirebaseAdmin.Messaging; using FirebaseAdmin; using Google.Apis.Auth.OAuth2; using HrynCo.NotificationService.Contracts.Messages; using HrynCo.NotificationService.DAL.Abstract.Push; using HrynCo.NotificationService.DAL.Abstract.PushChannels; public sealed class FirebasePushClient : IFirebasePushClient { public async Task SendAsync(Guid messageId, SendPushMessageData data, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) { var remaining = data.ExpiresAt - DateTimeOffset.UtcNow; if (remaining <= TimeSpan.Zero) return new PushDeliveryOutcome("Push expired."); return await SendMessageAsync(new Message { Token = data.Token, Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining }, Data = new Dictionary { ["version"] = "1", ["messageId"] = messageId.ToString("D"), ["bindingId"] = data.BindingId.ToString("D"), ["title"] = data.Title, ["body"] = data.Body, ["route"] = data.Route, ["expiresAt"] = data.ExpiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) } }, credentials, cancellationToken); } public Task SendTestAsync(string token, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) => SendMessageAsync(new Message { Token = token, Notification = new Notification { Title = "Notification Service test", Body = "This is a test notification from the channel administration page." }, Android = new AndroidConfig { Priority = Priority.High, TimeToLive = TimeSpan.FromMinutes(5) } }, credentials, cancellationToken); public async Task CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(30)); try { var credential = CreateCredential(credentials).CreateScoped("https://www.googleapis.com/auth/firebase.messaging"); await credential.UnderlyingCredential.GetAccessTokenForRequestAsync(cancellationToken: timeout.Token); return new PushDeliveryOutcome(null); } catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException) { return new PushDeliveryOutcome("Firebase OAuth authorization failed."); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return new PushDeliveryOutcome("Firebase authorization check timed out."); } catch (HttpRequestException) { return new PushDeliveryOutcome("Could not reach Firebase authorization service."); } } private static GoogleCredential CreateCredential(FirebaseServiceAccountCredentials credentials) => GoogleCredential.FromServiceAccountCredential(new ServiceAccountCredential( new ServiceAccountCredential.Initializer(credentials.ClientEmail) { ProjectId = credentials.ProjectId }.FromPrivateKey(credentials.PrivateKey))); private static async Task SendMessageAsync(Message message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(30)); FirebaseApp? app = null; try { app = FirebaseApp.Create(new AppOptions { ProjectId = credentials.ProjectId, Credential = CreateCredential(credentials) }, "push-" + Guid.NewGuid().ToString("N")); await FirebaseMessaging.GetMessaging(app).SendAsync(message, timeout.Token); return new PushDeliveryOutcome(null); } catch (FirebaseMessagingException exception) { var header = exception.HttpResponse?.Headers.RetryAfter; var retryAfter = header?.Delta ?? (header?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.Zero; return Classify(exception.MessagingErrorCode, retryAfter); } catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException) { return new PushDeliveryOutcome("FCM authorization failed."); } catch (FirebaseException) { return new PushDeliveryOutcome("FCM configuration failed."); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new PushRetryException(TimeSpan.FromMinutes(1)); } catch (HttpRequestException) { throw new PushRetryException(TimeSpan.FromMinutes(1)); } finally { app?.Delete(); } } internal static PushDeliveryOutcome Classify(MessagingErrorCode? code, TimeSpan retryAfter = default) => code switch { MessagingErrorCode.Unregistered => new PushDeliveryOutcome("FCM registration is no longer valid.", true), MessagingErrorCode.Unavailable or MessagingErrorCode.Internal or MessagingErrorCode.QuotaExceeded => throw new PushRetryException(retryAfter), MessagingErrorCode.SenderIdMismatch => new PushDeliveryOutcome("FCM sender configuration does not match."), MessagingErrorCode.InvalidArgument => new PushDeliveryOutcome("FCM rejected the push payload."), _ => new PushDeliveryOutcome("FCM authorization or configuration failure.") }; }