diff --git a/README.md b/README.md index 694aa3b..154c759 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Action Push Native -Action Push Native is a Rails push notification gem for mobile platforms, supporting APNs (Apple) and FCM (Google). +Action Push Native is a Rails push notification gem for mobile and web platforms, supporting APNs (Apple) and FCM (Google Android/Web). ## Installation @@ -87,12 +87,20 @@ shared: # See https://firebase.google.com/docs/cloud-messaging/auth-server encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %> + # Firebase project_id + project_id: your_project_id + + web: + # Uses the same Firebase project service account credentials as Android. + # See https://firebase.google.com/docs/cloud-messaging/auth-server + encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %> + # Firebase project_id project_id: your_project_id ``` This file contains the configuration for the push notification services you want to use. -The push notification services supported are `apple` (APNs) and `google` (FCM). +The push notification services supported are `apple` (APNs), `google` (FCM Android), and `web` (FCM Web Push). If you're configuring more than one app, see the section [Configuring multiple apps](#configuring-multiple-apps) below. ### Configuring multiple apps @@ -208,12 +216,13 @@ notification = ApplicationPushNotification You can configure custom platform payload to be sent with the notification. This is useful when you need to send additional data that is specific to the platform you are using. -You can use `with_apple` for Apple and `with_google` for Google: +You can use `with_apple` for Apple, `with_google` for Android, and `with_web` for Web: ```ruby notification = ApplicationPushNotification .with_apple(aps: { category: "observable", "thread-id": "greeting"}, "apns-priority": "1") .with_google(data: { badge: 1 }) + .with_web(webpush: { headers: { TTL: "300" }, data: { url: "https://example.com" } }) .new(title: "Hello world!") ``` @@ -223,6 +232,7 @@ default behaviour: ```ruby notification = ApplicationPushNotification .with_google(android: { notification: { notification_count: nil } }) + .with_web(webpush: { notification: { tag: "custom" } }) .new(title: "Hello world!", body: "Welcome to Action Push Native", badge: 1) ``` @@ -278,6 +288,67 @@ by adding extra arguments to the notification constructor: notification.deliver_later_to(device) ``` +### Registering Web Push Devices via API + +For web clients (including TWA-backed PWAs), obtain an FCM registration token in the browser and POST it to your backend as a `web` device. + +```js +import { initializeApp } from "firebase/app"; +import { getMessaging, getToken, isSupported } from "firebase/messaging"; + +const firebaseApp = initializeApp({ + apiKey: "...", + projectId: "...", + messagingSenderId: "...", + appId: "...", +}); + +async function registerPushDevice() { + if (!(await isSupported())) return; + + const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js"); + const messaging = getMessaging(firebaseApp); + const token = await getToken(messaging, { + serviceWorkerRegistration: registration, + vapidKey: "YOUR_WEB_PUSH_CERTIFICATE_KEY_PAIR_VAPID_KEY", + }); + + if (!token) return; + + await fetch("/push_devices", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device: { + platform: "web", + token, + name: navigator.userAgent, + }, + }), + credentials: "include", + }); +} +``` + +```ruby +# app/controllers/push_devices_controller.rb +class PushDevicesController < ApplicationController + protect_from_forgery with: :null_session + + def create + device = ApplicationPushDevice.find_or_initialize_by(token: device_params[:token]) + device.assign_attributes(device_params.merge(owner: current_user)) + device.save! + head :created + end + + private + def device_params + params.require(:device).permit(:platform, :token, :name) + end +end +``` + ### Using a custom Device model If using the default `ApplicationPushDevice` model does not fit your needs, you can create a custom @@ -311,6 +382,7 @@ end | :sound | The sound to play when the notification is received. | :high_priority | Whether the notification should be sent with high priority (default: true). | :google_data | The Google-specific payload for the notification. +| :web_data | The Web-specific payload for the notification (FCM Web). | :apple_data | The Apple-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc. | :data | The data payload for the notification, sent to all platforms. | ** | Any additional attributes passed to the constructor will be merged in the `context` hash. @@ -321,6 +393,7 @@ end |------------------|------------ | :with_apple | Set the Apple-specific payload for the notification. | :with_google | Set the Google-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc. +| :with_web | Set the Web-specific payload for the notification. | :with_data | Set the data payload for the notification, sent to all platforms. | :silent | Create a silent notification that does not trigger a visual alert on the device. diff --git a/app/models/action_push_native/device.rb b/app/models/action_push_native/device.rb index 2c6dff1..a115a59 100644 --- a/app/models/action_push_native/device.rb +++ b/app/models/action_push_native/device.rb @@ -8,7 +8,7 @@ class Device < ApplicationRecord belongs_to :owner, polymorphic: true, optional: true - enum :platform, { apple: "apple", google: "google" } + enum :platform, { apple: "apple", google: "google", web: "web" } def push(notification) notification.token = token diff --git a/lib/action_push_native.rb b/lib/action_push_native.rb index a472415..0e152f5 100644 --- a/lib/action_push_native.rb +++ b/lib/action_push_native.rb @@ -21,6 +21,8 @@ def self.service_for(platform, notification) Service::Apns.new(platform_config) when :google Service::Fcm.new(platform_config) + when :web + Service::FcmWeb.new(platform_config) else raise "ActionPushNative: '#{platform}' platform is unsupported" end diff --git a/lib/action_push_native/configured_notification.rb b/lib/action_push_native/configured_notification.rb index f97f79b..2b271e4 100644 --- a/lib/action_push_native/configured_notification.rb +++ b/lib/action_push_native/configured_notification.rb @@ -29,6 +29,11 @@ def with_google(google_data) self end + def with_web(web_data) + @options[:web_data] = @options.fetch(:web_data, {}).merge(web_data) + self + end + private attr_reader :notification_class, :options end diff --git a/lib/action_push_native/notification.rb b/lib/action_push_native/notification.rb index c080eb8..2698898 100644 --- a/lib/action_push_native/notification.rb +++ b/lib/action_push_native/notification.rb @@ -7,7 +7,7 @@ module ActionPushNative class Notification extend ActiveModel::Callbacks - attr_accessor :title, :body, :badge, :thread_id, :sound, :high_priority, :apple_data, :google_data, :data + attr_accessor :title, :body, :badge, :thread_id, :sound, :high_priority, :apple_data, :google_data, :web_data, :data attr_accessor :context attr_accessor :token @@ -22,7 +22,7 @@ def queue_as(name) self.queue_name = name end - delegate :with_data, :silent, :with_apple, :with_google, to: :configured_notification + delegate :with_data, :silent, :with_apple, :with_google, :with_web, to: :configured_notification private def configured_notification @@ -40,11 +40,12 @@ def configured_notification # high_priority - Whether to send the notification with high priority (default: true). # For silent notifications is recommended to set this to false # apple_data - Apple Push Notification Service (APNS) specific data - # google_data - Firebase Cloud Messaging (FCM) specific data + # google_data - Firebase Cloud Messaging (FCM) specific data for Android + # web_data - Firebase Cloud Messaging (FCM) specific data for Web Push # data - Custom data to be sent with the notification # # Any extra attributes are set inside the `context` hash. - def initialize(title: nil, body: nil, badge: nil, thread_id: nil, sound: nil, high_priority: true, apple_data: {}, google_data: {}, data: {}, **context) + def initialize(title: nil, body: nil, badge: nil, thread_id: nil, sound: nil, high_priority: true, apple_data: {}, google_data: {}, web_data: {}, data: {}, **context) @title = title @body = body @badge = badge @@ -53,6 +54,7 @@ def initialize(title: nil, body: nil, badge: nil, thread_id: nil, sound: nil, hi @high_priority = high_priority @apple_data = apple_data @google_data = google_data + @web_data = web_data @data = data @context = context end @@ -79,6 +81,7 @@ def as_json high_priority: high_priority, apple_data: apple_data, google_data: google_data, + web_data: web_data, data: data, **context }.compact diff --git a/lib/action_push_native/service/fcm_web.rb b/lib/action_push_native/service/fcm_web.rb new file mode 100644 index 0000000..dfdc2ee --- /dev/null +++ b/lib/action_push_native/service/fcm_web.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ActionPushNative + module Service + class FcmWeb < Fcm + private + def payload_from(notification) + deep_compact({ + message: { + token: notification.token, + data: notification.data ? stringify(notification.data) : {}, + webpush: webpush_payload_from(notification) + }.deep_merge(notification.web_data ? stringify_data(notification.web_data) : {}) + }) + end + + def webpush_payload_from(notification) + notification_payload = { + title: notification.title, + body: notification.body, + tag: notification.thread_id + }.compact + + headers = urgency_header_for(notification) + + { + notification: notification_payload.presence, + headers: headers.presence + }.compact + end + + def urgency_header_for(notification) + urgency = notification.high_priority == false ? "normal" : "high" + { Urgency: urgency }.compact + end + + def deep_compact(payload) + payload.dig(:message, :webpush, :notification).try(&:compact!) + payload.dig(:message, :webpush, :headers).try(&:compact!) + payload.dig(:message, :webpush).try(&:compact!) + payload[:message][:data] = payload[:message][:data].presence if payload[:message][:data].respond_to?(:presence) + payload[:message].compact! + payload + end + + def stringify_data(web_data) + super.tap do |payload| + if payload[:webpush]&.key?(:data) + payload[:webpush][:data] = stringify(payload[:webpush][:data]) + end + end + end + end + end +end diff --git a/test/fixtures/files/config/push_calendar.yml b/test/fixtures/files/config/push_calendar.yml index f8d4b69..a51302d 100644 --- a/test/fixtures/files/config/push_calendar.yml +++ b/test/fixtures/files/config/push_calendar.yml @@ -11,3 +11,7 @@ google: application: encryption_key: your_service_account_json_file project_id: your_project_id +web: + application: + encryption_key: your_service_account_json_file + project_id: your_project_id diff --git a/test/lib/action_push_native/action_push_test.rb b/test/lib/action_push_native/action_push_test.rb index f3291b4..dd7b9a2 100644 --- a/test/lib/action_push_native/action_push_test.rb +++ b/test/lib/action_push_native/action_push_test.rb @@ -61,6 +61,30 @@ class ActionPushNativeTest < ActiveSupport::TestCase assert_equal expected_config, config end + test "config_for web" do + stub_config("push_calendar.yml") + config = ActionPushNative.config_for :web, CalendarPushNotification.new + expected_config = { + encryption_key: "your_service_account_json_file", + project_id: "your_project_id" + } + assert_equal expected_config, config + end + + test "service_for web" do + notification = CalendarPushNotification.new(title: "Hello Web") + stub_config("push_calendar.yml") + + service = ActionPushNative.service_for(:web, notification) + + assert_kind_of ActionPushNative::Service::FcmWeb, service + expected_config = { + encryption_key: "your_service_account_json_file", + project_id: "your_project_id" + } + assert_equal expected_config, service.send(:config) + end + private def stub_config(name) Rails.application.stubs(:config_for).returns(YAML.load_file(file_fixture("config/#{name}"), symbolize_names: true)) diff --git a/test/lib/action_push_native/service/fcm_web_test.rb b/test/lib/action_push_native/service/fcm_web_test.rb new file mode 100644 index 0000000..74ccd37 --- /dev/null +++ b/test/lib/action_push_native/service/fcm_web_test.rb @@ -0,0 +1,86 @@ +require "test_helper" + +module ActionPushNative + module Service + class FcmWebTest < ActiveSupport::TestCase + setup do + @notification = build_notification + @fcm_web = ActionPushNative.service_for(:web, @notification) + stub_authorizer + end + + test "push" do + payload = { + message: { + token: "123", + data: { person: "Jacopo" }, + webpush: { + notification: { + title: "Hi!", + body: "This is a web push notification", + tag: "thread-123" + }, + headers: { Urgency: "high" }, + data: { badge: "1", url: "https://example.test" } + } + } + } + + stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send"). + with(body: payload.to_json, headers: { "Authorization" => "Bearer fake_access_token" }). + to_return(status: 200) + + assert_nothing_raised do + @fcm_web.push(@notification) + end + end + + test "push with low priority urgency" do + @notification.high_priority = false + payload = { + message: { + token: "123", + data: { person: "Jacopo" }, + webpush: { + notification: { + title: "Hi!", + body: "This is a web push notification", + tag: "thread-123" + }, + headers: { Urgency: "normal" }, + data: { badge: "1", url: "https://example.test" } + } + } + } + + stub_request(:post, "https://fcm.googleapis.com/v1/projects/your_project_id/messages:send"). + with(body: payload.to_json, headers: { "Authorization" => "Bearer fake_access_token" }). + to_return(status: 200) + + assert_nothing_raised do + @fcm_web.push(@notification) + end + end + + private + def build_notification + ActionPushNative::Notification. + with_web(webpush: { data: { badge: 1, url: "https://example.test" } }). + with_data(person: "Jacopo"). + new( + title: "Hi!", + body: "This is a web push notification", + thread_id: "thread-123" + ).tap do |notification| + notification.token = "123" + end + end + + def stub_authorizer + authorizer = stub("authorizer") + authorizer.stubs(:fetch_access_token!).returns({ "access_token" => "fake_access_token", "expires_in" => 3599 }) + Google::Auth::ServiceAccountCredentials.stubs(:make_creds).returns(authorizer) + end + end + end +end