Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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!")
```

Expand All @@ -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)
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion app/models/action_push_native/device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/action_push_native.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/action_push_native/configured_notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions lib/action_push_native/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions lib/action_push_native/service/fcm_web.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions test/fixtures/files/config/push_calendar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions test/lib/action_push_native/action_push_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
86 changes: 86 additions & 0 deletions test/lib/action_push_native/service/fcm_web_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading