action_push_native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c37771ec586061a95004904931f0ffc841177a104fa27b8f5c9404bdef33bcc8
4
+ data.tar.gz: d39db792468a2516ce99d9330f0612b1ba1ef1ba194ee0cfa3b4f493164c214e
5
+ SHA512:
6
+ metadata.gz: 761d8d0a9eeb0f4914fa2129dda438958babf02354be17ed87b8264ebd052d10ff73a98723afbeb40c6dc0253de81f26ed37fc93588b3315b08264181cee1554
7
+ data.tar.gz: b0351f879b5fcf6a244c053bcd375ea4803071ec82d8d56329bd22e4eb7a346f8219f56b34497c27faf705a094490164399df7526dbf04daf89b8d73344d9330
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 37signals, LLC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,329 @@
1
+ # Action Push Native
2
+
3
+ Action Push Native is a Rails push notification gem for mobile platforms, supporting APNs (Apple) and FCM (Google).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ 1. bundle add action_push_native
9
+ 2. bin/rails g action_push_native:install
10
+ 3. bin/rails action_push_native:install:migrations
11
+ 4. bin/rails db:migrate
12
+ ```
13
+
14
+ This will install the gem and run the necessary migrations to set up the database.
15
+
16
+ ## Configuration
17
+
18
+ The installation will create:
19
+
20
+ - `app/models/application_push_notification.rb`
21
+ - `app/jobs/application_push_notification_job.rb`
22
+ - `app/models/application_push_device.rb`
23
+ - `config/push.yml`
24
+
25
+ `app/models/application_push_notification.rb`:
26
+
27
+ ```ruby
28
+ class ApplicationPushNotification < ActionPushNative::Notification
29
+ # Set a custom job queue_name
30
+ queue_as :realtime
31
+
32
+ # Controls whether push notifications are enabled (default: !Rails.env.test?)
33
+ self.enabled = Rails.env.production?
34
+
35
+ # Define a custom callback to modify or abort the notification before it is sent
36
+ before_delivery do |notification|
37
+ throw :abort if Notification.find(notification.context[:notification_id]).expired?
38
+ end
39
+ end
40
+ ```
41
+
42
+ Used to create and send push notifications. You can customize it by subclassing or
43
+ you can change the application defaults by editing it directly.
44
+
45
+ `app/jobs/application_push_notification_job.rb`:
46
+
47
+ ```ruby
48
+ class ApplicationPushNotificationJob < ActionPushNative::NotificationJob
49
+ # Enable logging job arguments (default: false)
50
+ self.log_arguments = true
51
+
52
+ # Report job retries via the `Rails.error` reporter (default: false)
53
+ self.report_job_retries = true
54
+ end
55
+ ```
56
+
57
+ Job class that processes the push notifications. You can customize it by editing it
58
+ directly in your application.
59
+
60
+ `app/models/application_push_device.rb`:
61
+
62
+ ```ruby
63
+ class ApplicationPushDevice < ActionPushNative::Device
64
+ # Customize TokenError handling (default: destroy!)
65
+ # rescue_from (ActionPushNative::TokenError) { Rails.logger.error("Device #{id} token is invalid") }
66
+ end
67
+ ```
68
+
69
+ This represents a push notification device. You can customize it by editing it directly in your application.
70
+
71
+ `config/push.yml`:
72
+
73
+ ```yaml
74
+ shared:
75
+ apple:
76
+ # Token auth params
77
+ # See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
78
+ key_id: your_key_id
79
+ encryption_key: your_apple_encryption_key
80
+
81
+ team_id: your_apple_team_id
82
+ # Your identifier found on https://developer.apple.com/account/resources/identifiers/list
83
+ topic: your.bundle.identifier
84
+
85
+ google:
86
+ # Your Firebase project service account credentials
87
+ # See https://firebase.google.com/docs/cloud-messaging/auth-server
88
+ encryption_key: your_service_account_json_file
89
+
90
+ # Firebase project_id
91
+ project_id: your_project_id
92
+ ```
93
+
94
+ This file contains the configuration for the push notification services you want to use.
95
+ The push notification services supported are `apple` (APNs) and `google` (FCM).
96
+ If you're configuring more than one app, see the section [Configuring multiple apps](#configuring-multiple-apps) below.
97
+
98
+ ### Configuring multiple apps
99
+
100
+ You can send push notifications to multiple apps using different notification classes.
101
+ Each notification class need to inherit from `ApplicationPushNotification` and set `self.application`, to a key set in `push.yml`
102
+ for each supported platform. You can also (optionally) set a shared `application` option in `push.yml`.
103
+ This acts as the base configuration for that platform, and its values will be merged (and overridden) with the matching app-specific configuration.
104
+
105
+ In the example below we are configuring two apps: `calendar` and `email` using respectively the
106
+ `CalendarPushNotification` and `EmailPushNotification` notification classes.
107
+
108
+ ```ruby
109
+ class CalendarPushNotification < ApplicationPushNotification
110
+ self.application = "calendar"
111
+
112
+ # Custom notification logic for calendar app
113
+ end
114
+
115
+ class EmailPushNotification < ApplicationPushNotification
116
+ self.application = "email"
117
+
118
+ # Custom notification logic for email app
119
+ end
120
+ ```
121
+
122
+ ```yaml
123
+ shared:
124
+ apple:
125
+ # Base configuration for Apple platform
126
+ # This will be merged with the app-specific configuration
127
+ application:
128
+ team_id: your_apple_team_id
129
+
130
+ calendar:
131
+ # Token auth params
132
+ # See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
133
+ key_id: calendar_key_id
134
+ encryption_key: calendar_apple_encryption_key
135
+ # Your identifier found on https://developer.apple.com/account/resources/identifiers/list
136
+ topic: calendar.bundle.identifier
137
+
138
+ email:
139
+ # Token auth params
140
+ # See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
141
+ key_id: email_key_id
142
+ encryption_key: email_apple_encryption_key
143
+ # Your identifier found on https://developer.apple.com/account/resources/identifiers/list
144
+ topic: email.bundle.identifier
145
+
146
+ google:
147
+ calendar:
148
+ # Your Firebase project service account credentials
149
+ # See https://firebase.google.com/docs/cloud-messaging/auth-server
150
+ encryption_key: calendar_service_account_json_file
151
+
152
+ # Firebase project_id
153
+ project_id: calendar_project_id
154
+
155
+ email:
156
+ # Your Firebase project service account credentials
157
+ # See https://firebase.google.com/docs/cloud-messaging/auth-server
158
+ encryption_key: email_service_account_json_file
159
+
160
+ # Firebase project_id
161
+ project_id: email_project_id
162
+ ```
163
+
164
+ ## Usage
165
+
166
+ ### Create and send a notification asynchronously to a device
167
+
168
+ ```ruby
169
+ device = ApplicationPushDevice.create! \
170
+ name: "iPhone 16",
171
+ token: "6c267f26b173cd9595ae2f6702b1ab560371a60e7c8a9e27419bd0fa4a42e58f",
172
+ platform: "apple"
173
+
174
+ notification = ApplicationPushNotification.new \
175
+ title: "Hello world!",
176
+ body: "Welcome to Action Push Native"
177
+
178
+ notification.deliver_later_to(device)
179
+ ```
180
+
181
+ `deliver_later_to` supports also an array of devices:
182
+
183
+ ```ruby
184
+ notification.deliver_later_to([ device1, device2 ])
185
+ ```
186
+
187
+ A notification can also be delivered synchronously using `deliver_to`:
188
+
189
+ ```ruby
190
+ notification.deliver_to(device)
191
+ ```
192
+
193
+ It is recommended to send notifications asynchronously using `deliver_later_to`.
194
+ This ensures error handling and retry logic are in place, and avoids blocking your application's execution.
195
+
196
+ ### Application data attributes
197
+
198
+ You can pass custom data to the application using the `with_data` method:
199
+
200
+ ```ruby
201
+ notification = ApplicationPushNotification
202
+ .with_data({ badge: "1" })
203
+ .new(title: "Welcome to Action Push Native")
204
+ ```
205
+
206
+ ### Custom platform Payload
207
+
208
+ You can configure custom platform payload to be sent with the notification. This is useful when you
209
+ need to send additional data that is specific to the platform you are using.
210
+
211
+ You can use `with_apple` for Apple and `with_google` for Google:
212
+
213
+ ```ruby
214
+ notification = ApplicationPushNotification
215
+ .with_apple(category: "observable")
216
+ .with_google(data: { badge: 1 })
217
+ .new(title: "Hello world!")
218
+ ```
219
+
220
+ The platform payload takes precedence over the other fields, and you can use it to override the
221
+ default behaviour:
222
+
223
+ ```ruby
224
+ notification = ApplicationPushNotification
225
+ .with_google(android: { notification: { notification_count: nil } })
226
+ .new(title: "Hello world!", body: "Welcome to Action Push Native", badge: 1)
227
+ ```
228
+
229
+ This will unset the default `notification_count` (`badge`) field in the Google payload, while keeping `title`
230
+ and `body`.
231
+
232
+ ### Silent Notifications
233
+
234
+ You can create a silent notification via the `silent` method:
235
+
236
+ ```ruby
237
+ notification = ApplicationPushNotification.silent.with_data(id: 1)
238
+ ```
239
+
240
+ This will create a silent notification for both Apple and Google platforms and sets an application
241
+ data field of `{ id: 1 }` for both platforms. Silent push notification must not contain any attribute which would trigger
242
+ a visual notification on the device, such as `title`, `body`, `badge`, etc.
243
+
244
+ ### Linking a Device to a Record
245
+
246
+ A Device can be associated with any record in your application via the `owner` polymorphic association:
247
+
248
+ ```ruby
249
+ user = User.find_by_email_address("jacopo@37signals.com")
250
+
251
+ ApplicationPushDevice.create! \
252
+ name: "iPhone 16",
253
+ token: "6c267f26b173cd9595ae2f6702b1ab560371a60e7c8a9e27419bd0fa4a42e58f",
254
+ platform: "apple",
255
+ owner: user
256
+ ```
257
+ ### `before_delivery` callback
258
+
259
+ You can specify Active Record like callbacks for the `delivery` method. For example, you can modify
260
+ or cancel the notification by specifying a custom `before_delivery` block. The callback has access
261
+ to the `notification` object. You can also pass additional context data to the notification
262
+ by adding extra arguments to the notification constructor:
263
+
264
+ ```ruby
265
+ class CalendarPushNotification < ApplicationPushNotification
266
+ before_delivery do |notification|
267
+ throw :abort if Calendar.find(notification.context[:calendar_id]).expired?
268
+ end
269
+ end
270
+
271
+ data = { calendar_id: @calendar.id, identity_id: @identity.id }
272
+
273
+ notification = CalendarPushNotification
274
+ .with_apple(custom_payload: data)
275
+ .with_google(data: data)
276
+ .new(calendar_id: 123)
277
+
278
+ notification.deliver_later_to(device)
279
+ ```
280
+
281
+ ### Using a custom Device model
282
+
283
+ If using the default `ApplicationPushDevice` model does not fit your needs, you can create a custom
284
+ device model, as long as:
285
+
286
+ 1. It can be serialized and deserialized by `ActiveJob`.
287
+ 2. It responds to the `token` and `platform` methods.
288
+ 3. It implements a `push` method like this:
289
+
290
+ ```ruby
291
+ class CustomDevice
292
+ # Your custom device attributes and methods...
293
+
294
+ def push(notification)
295
+ notification.token = token
296
+ ActionPushNative.service_for(platform, notification).push(notification)
297
+ rescue ActionPushNative::TokenError => error
298
+ # Custom token error handling
299
+ end
300
+ end
301
+ ```
302
+
303
+ ## `ActionPushNative::Notification` attributes
304
+
305
+ | Name | Description
306
+ |------------------|------------
307
+ | :title | The title of the notification.
308
+ | :body | The body of the notification.
309
+ | :badge | The badge number to display on the app icon.
310
+ | :thread_id | The thread identifier for grouping notifications.
311
+ | :sound | The sound to play when the notification is received.
312
+ | :high_priority | Whether the notification should be sent with high priority (default: true).
313
+ | :google_data | The Google-specific payload for the notification.
314
+ | :apple_data | The Apple-specific payload for the notification.
315
+ | :data | The data payload for the notification, sent to all platforms.
316
+ | ** | Any additional attributes passed to the constructor will be merged in the `context` hash.
317
+
318
+ ### Factory methods
319
+
320
+ | Name | Description
321
+ |------------------|------------
322
+ | :with_apple | Set the Apple-specific payload for the notification.
323
+ | :with_google | Set the Google-specific payload for the notification.
324
+ | :with_data | Set the data payload for the notification, sent to all platforms.
325
+ | :silent | Create a silent notification that does not trigger a visual alert on the device.
326
+
327
+ ## License
328
+
329
+ Action Push Native is licensed under MIT.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPushNative
4
+ class NotificationJob < ActiveJob::Base
5
+ self.log_arguments = false
6
+
7
+ class_attribute :report_job_retries, default: false
8
+
9
+ discard_on ActiveJob::DeserializationError
10
+ discard_on BadDeviceTopicError do |_job, error|
11
+ Rails.error.report(error)
12
+ end
13
+
14
+ class << self
15
+ def retry_options
16
+ Rails.version >= "8.1" ? { report: report_job_retries } : {}
17
+ end
18
+
19
+ # Exponential backoff starting from a minimum of 1 minute, capped at 60m as suggested by FCM:
20
+ # https://firebase.google.com/docs/cloud-messaging/scale-fcm#errors
21
+ #
22
+ # | Executions | Delay (rounded minutes) |
23
+ # |------------|-------------------------|
24
+ # | 1 | 1 |
25
+ # | 2 | 2 |
26
+ # | 3 | 4 |
27
+ # | 4 | 8 |
28
+ # | 5 | 16 |
29
+ # | 6 | 32 |
30
+ # | 7 | 60 (cap) |
31
+ def exponential_backoff_delay(executions)
32
+ base_wait = 1.minute
33
+ delay = base_wait * (2**(executions - 1))
34
+ jitter = 0.15
35
+ jitter_delay = rand * delay * jitter
36
+
37
+ [ delay + jitter_delay, 60.minutes ].min
38
+ end
39
+ end
40
+
41
+ with_options retry_options do
42
+ retry_on TimeoutError, wait: 1.minute
43
+ retry_on ConnectionError, ConnectionPool::TimeoutError, attempts: 20
44
+
45
+ # Altough unexpected, these are short-lived errors that can be retried most of the times.
46
+ retry_on ForbiddenError, BadRequestError
47
+ end
48
+
49
+ with_options wait: ->(executions) { exponential_backoff_delay(executions) }, attempts: 6, **retry_options do
50
+ retry_on TooManyRequestsError, ServiceUnavailableError, InternalServerError
51
+ retry_on Signet::RemoteServerError
52
+ end
53
+
54
+ def perform(notification_class, notification_attributes, device)
55
+ notification_class.constantize.new(**notification_attributes).deliver_to(device)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPushNative
4
+ class Device < ApplicationRecord
5
+ include ActiveSupport::Rescuable
6
+
7
+ rescue_from(TokenError) { destroy! }
8
+
9
+ belongs_to :owner, polymorphic: true, optional: true
10
+
11
+ enum :platform, { apple: "apple", google: "google" }
12
+
13
+ def push(notification)
14
+ notification.token = token
15
+ ActionPushNative.service_for(platform, notification).push(notification)
16
+ rescue => error
17
+ rescue_with_handler(error) || raise
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ class CreateActionPushNativeDevice < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :action_push_native_devices do |t|
4
+ t.string :name
5
+ t.string :platform, null: false
6
+ t.string :token, null: false
7
+ t.belongs_to :owner, polymorphic: true
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,35 @@
1
+ module ActionPushNative
2
+ class ConfiguredNotification
3
+ def initialize(notification_class)
4
+ @notification_class = notification_class
5
+ @options = {}
6
+ end
7
+
8
+ def new(**attributes)
9
+ notification_class.new(**attributes.merge(options))
10
+ end
11
+
12
+ def with_data(data)
13
+ @options[:data] = @options.fetch(:data, {}).merge(data)
14
+ self
15
+ end
16
+
17
+ def silent
18
+ @options = options.merge(high_priority: false)
19
+ with_apple(content_available: 1)
20
+ end
21
+
22
+ def with_apple(apple_data)
23
+ @options[:apple_data] = @options.fetch(:apple_data, {}).merge(apple_data)
24
+ self
25
+ end
26
+
27
+ def with_google(google_data)
28
+ @options[:google_data] = @options.fetch(:google_data, {}).merge(google_data)
29
+ self
30
+ end
31
+
32
+ private
33
+ attr_reader :notification_class, :options
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPushNative
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ActionPushNative
6
+
7
+ initializer "action_push_native.config" do |app|
8
+ app.paths.add "config/push", with: "config/push.yml"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPushNative
4
+ class TimeoutError < StandardError; end
5
+ class ConnectionError < StandardError; end
6
+
7
+ class BadRequestError < StandardError; end
8
+ class ForbiddenError < StandardError; end
9
+ class PayloadTooLargeError < StandardError; end
10
+ class TooManyRequestsError < StandardError; end
11
+ class ServiceUnavailableError < StandardError; end
12
+ class InternalServerError < StandardError; end
13
+ class BadDeviceTopicError < StandardError; end
14
+ class NotFoundError < StandardError; end
15
+
16
+ class TokenError < StandardError; end
17
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPushNative
4
+ # = Action Push Native Notification
5
+ #
6
+ # A notification that can be delivered to devices.
7
+ class Notification
8
+ extend ActiveModel::Callbacks
9
+
10
+ attr_accessor :title, :body, :badge, :thread_id, :sound, :high_priority, :apple_data, :google_data, :data
11
+ attr_accessor :context
12
+ attr_accessor :token
13
+
14
+ define_model_callbacks :delivery
15
+
16
+ class_attribute :queue_name, default: ActiveJob::Base.default_queue_name
17
+ class_attribute :enabled, default: !Rails.env.test?
18
+ class_attribute :application
19
+
20
+ class << self
21
+ def queue_as(name)
22
+ self.queue_name = name
23
+ end
24
+
25
+ delegate :with_data, :silent, :with_apple, :with_google, to: :configured_notification
26
+
27
+ private
28
+ def configured_notification
29
+ ConfiguredNotification.new(self)
30
+ end
31
+ end
32
+
33
+ # === Attributes
34
+ #
35
+ # title - The title
36
+ # body - The message body
37
+ # badge - The badge number to display on the app icon
38
+ # thread_id - The thread ID for grouping notifications
39
+ # sound - The sound to play when the notification is received
40
+ # high_priority - Whether to send the notification with high priority (default: true).
41
+ # For silent notifications is recommended to set this to false
42
+ # apple_data - Apple Push Notification Service (APNS) specific data
43
+ # google_data - Firebase Cloud Messaging (FCM) specific data
44
+ # data - Custom data to be sent with the notification
45
+ #
46
+ # Any extra attributes are set inside the `context` hash.
47
+ def initialize(title: nil, body: nil, badge: nil, thread_id: nil, sound: nil, high_priority: true, apple_data: {}, google_data: {}, data: {}, **context)
48
+ @title = title
49
+ @body = body
50
+ @badge = badge
51
+ @thread_id = thread_id
52
+ @sound = sound
53
+ @high_priority = high_priority
54
+ @apple_data = apple_data
55
+ @google_data = google_data
56
+ @data = data
57
+ @context = context
58
+ end
59
+
60
+ def deliver_to(device)
61
+ if enabled
62
+ run_callbacks(:delivery) { device.push(self) }
63
+ end
64
+ end
65
+
66
+ def deliver_later_to(devices)
67
+ Array(devices).each do |device|
68
+ ApplicationPushNotificationJob.set(queue: queue_name).perform_later(self.class.name, self.as_json, device)
69
+ end
70
+ end
71
+
72
+ def as_json
73
+ {
74
+ title: title,
75
+ body: body,
76
+ badge: badge,
77
+ thread_id: thread_id,
78
+ sound: sound,
79
+ high_priority: high_priority,
80
+ apple_data: apple_data,
81
+ google_data: google_data,
82
+ data: data,
83
+ **context
84
+ }.compact
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPushNative
4
+ module Service
5
+ class Apns
6
+ DEFAULT_TIMEOUT = 30.seconds
7
+ DEFAULT_POOL_SIZE = 5
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ # Per-application connection pools
14
+ cattr_accessor :connection_pools
15
+
16
+ def push(notification)
17
+ reset_connection_error
18
+
19
+ connection_pool.with do |connection|
20
+ rescue_and_reraise_network_errors do
21
+ apnotic_notification = apnotic_notification_from(notification)
22
+ Rails.logger.info("Pushing APNs notification: #{apnotic_notification.apns_id}")
23
+
24
+ response = connection.push \
25
+ apnotic_notification,
26
+ timeout: config[:request_timeout] || DEFAULT_TIMEOUT
27
+ raise connection_error if connection_error
28
+ handle_response_error(response) unless response&.ok?
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+ attr_reader :config, :connection_error
35
+
36
+ def reset_connection_error
37
+ @connection_error = nil
38
+ end
39
+
40
+ def connection_pool
41
+ self.class.connection_pools ||= {}
42
+ self.class.connection_pools[config] ||= build_connection_pool
43
+ end
44
+
45
+ def build_connection_pool
46
+ build_method = config[:connect_to_development_server] ? "development" : "new"
47
+ Apnotic::ConnectionPool.public_send(build_method, {
48
+ auth_method: :token,
49
+ cert_path: StringIO.new(config.fetch(:encryption_key)),
50
+ key_id: config.fetch(:key_id),
51
+ team_id: config.fetch(:team_id)
52
+ }, size: config[:connection_pool_size] || DEFAULT_POOL_SIZE) do |connection|
53
+ # Prevents the main thread from crashing collecting the connection error from the off-thread
54
+ # and raising it afterwards.
55
+ connection.on(:error) { |error| @connection_error = error }
56
+ end
57
+ end
58
+
59
+ def rescue_and_reraise_network_errors
60
+ begin
61
+ yield
62
+ rescue Errno::ETIMEDOUT => e
63
+ raise ActionPushNative::TimeoutError, e.message
64
+ rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
65
+ raise ActionPushNative::ConnectionError, e.message
66
+ rescue OpenSSL::SSL::SSLError => e
67
+ if e.message.include?("SSL_connect")
68
+ raise ActionPushNative::ConnectionError, e.message
69
+ else
70
+ raise
71
+ end
72
+ end
73
+ end
74
+
75
+ PRIORITIES = { high: 10, normal: 5 }.freeze
76
+
77
+ def apnotic_notification_from(notification)
78
+ Apnotic::Notification.new(notification.token).tap do |n|
79
+ n.topic = config.fetch(:topic)
80
+ n.alert = { title: notification.title, body: notification.body }.compact
81
+ n.badge = notification.badge
82
+ n.thread_id = notification.thread_id
83
+ n.sound = notification.sound
84
+ n.priority = notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal]
85
+ n.custom_payload = notification.data
86
+ notification.apple_data&.each do |key, value|
87
+ n.public_send("#{key.to_s.underscore}=", value)
88
+ end
89
+ end
90
+ end
91
+
92
+ def handle_response_error(response)
93
+ code = response&.status
94
+ reason = response.body["reason"] if response
95
+
96
+ Rails.logger.error("APNs response error #{code}: #{reason}") if reason
97
+
98
+ case [ code, reason ]
99
+ in [ nil, _ ]
100
+ raise ActionPushNative::TimeoutError
101
+ in [ "400", "BadDeviceToken" ]
102
+ raise ActionPushNative::TokenError, reason
103
+ in [ "400", "DeviceTokenNotForTopic" ]
104
+ raise ActionPushNative::BadDeviceTopicError, reason
105
+ in [ "400", _ ]
106
+ raise ActionPushNative::BadRequestError, reason
107
+ in [ "403", _ ]
108
+ raise ActionPushNative::ForbiddenError, reason
109
+ in [ "404", _ ]
110
+ raise ActionPushNative::NotFoundError, reason
111
+ in [ "410", _ ]
112
+ raise ActionPushNative::TokenError, reason
113
+ in [ "413", _ ]
114
+ raise ActionPushNative::PayloadTooLargeError, reason
115
+ in [ "429", _ ]
116
+ raise ActionPushNative::TooManyRequestsError, reason
117
+ in [ "503", _ ]
118
+ raise ActionPushNative::ServiceUnavailableError, reason
119
+ else
120
+ raise ActionPushNative::InternalServerError, reason
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPushNative
4
+ module Service
5
+ class Fcm
6
+ # FCM suggests at least a 10s timeout for requests, we set 15 to add some buffer.
7
+ # https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts
8
+ DEFAULT_TIMEOUT = 15.seconds
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def push(notification)
15
+ response = post_request payload_from(notification)
16
+ handle_error(response) unless response.code == "200"
17
+ end
18
+
19
+ private
20
+ attr_reader :config
21
+
22
+ def payload_from(notification)
23
+ deep_compact({
24
+ message: {
25
+ token: notification.token,
26
+ data: notification.data ? stringify(notification.data) : {},
27
+ android: {
28
+ notification: {
29
+ title: notification.title,
30
+ body: notification.body,
31
+ notification_count: notification.badge,
32
+ sound: notification.sound
33
+ },
34
+ collapse_key: notification.thread_id,
35
+ priority: notification.high_priority == true ? "high" : "normal"
36
+ }
37
+ }.deep_merge(notification.google_data ? stringify_data(notification.google_data) : {})
38
+ })
39
+ end
40
+
41
+ def deep_compact(payload)
42
+ payload.dig(:message, :android, :notification).try(&:compact!)
43
+ payload.dig(:message, :android).try(&:compact!)
44
+ payload[:message].compact!
45
+ payload
46
+ end
47
+
48
+ # FCM requires data values to be strings.
49
+ def stringify_data(google_data)
50
+ google_data.tap do |payload|
51
+ payload[:data] = stringify(payload[:data]) if payload[:data]
52
+ end
53
+ end
54
+
55
+ def stringify(hash)
56
+ hash.compact.transform_values(&:to_s)
57
+ end
58
+
59
+ def post_request(payload)
60
+ uri = URI("https://fcm.googleapis.com/v1/projects/#{config.fetch(:project_id)}/messages:send")
61
+ request = Net::HTTP::Post.new(uri)
62
+ request["Authorization"] = "Bearer #{access_token}"
63
+ request["Content-Type"] = "application/json"
64
+ request.body = payload.to_json
65
+
66
+ rescue_and_reraise_network_errors do
67
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: config[:request_timeout] || DEFAULT_TIMEOUT) do |http|
68
+ http.request(request)
69
+ end
70
+ end
71
+ end
72
+
73
+ def rescue_and_reraise_network_errors
74
+ yield
75
+ rescue Net::ReadTimeout, Net::OpenTimeout => e
76
+ raise ActionPushNative::TimeoutError, e.message
77
+ rescue Errno::ECONNRESET, SocketError => e
78
+ raise ActionPushNative::ConnectionError, e.message
79
+ rescue OpenSSL::SSL::SSLError => e
80
+ if e.message.include?("SSL_connect")
81
+ raise ActionPushNative::ConnectionError, e.message
82
+ else
83
+ raise
84
+ end
85
+ end
86
+
87
+ def access_token
88
+ authorizer = Google::Auth::ServiceAccountCredentials.make_creds \
89
+ json_key_io: StringIO.new(config.fetch(:encryption_key)),
90
+ scope: "https://www.googleapis.com/auth/firebase.messaging"
91
+ authorizer.fetch_access_token!["access_token"]
92
+ end
93
+
94
+ def handle_error(response)
95
+ code = response.code
96
+ reason = \
97
+ begin
98
+ JSON.parse(response.body).dig("error", "message")
99
+ rescue JSON::ParserError
100
+ response.body
101
+ end
102
+
103
+ Rails.logger.error("FCM response error #{code}: #{reason}")
104
+
105
+ case
106
+ when reason =~ /message is too big/i
107
+ raise ActionPushNative::PayloadTooLargeError, reason
108
+ when code == "400"
109
+ raise ActionPushNative::BadRequestError, reason
110
+ when code == "404"
111
+ raise ActionPushNative::TokenError, reason
112
+ when code.in?([ "401", "403" ])
113
+ raise ActionPushNative::ForbiddenError, reason
114
+ when code == "429"
115
+ raise ActionPushNative::TooManyRequestsError, reason
116
+ when code == "503"
117
+ raise ActionPushNative::ServiceUnavailableError, reason
118
+ else
119
+ raise ActionPushNative::InternalServerError, reason
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,3 @@
1
+ module ActionPushNative
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "action_push_native/engine"
5
+ require "action_push_native/errors"
6
+ require "net/http"
7
+ require "apnotic"
8
+ require "googleauth"
9
+
10
+ loader= Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
11
+ loader.ignore("#{__dir__}/generators")
12
+ loader.ignore("#{__dir__}/action_push_native/errors.rb")
13
+ loader.setup
14
+
15
+ module ActionPushNative
16
+ def self.service_for(platform, notification)
17
+ platform_config = config_for(platform, notification)
18
+
19
+ case platform.to_sym
20
+ when :apple
21
+ Service::Apns.new(platform_config)
22
+ when :google
23
+ Service::Fcm.new(platform_config)
24
+ else
25
+ raise "ActionPushNative: '#{platform}' platform is unsupported"
26
+ end
27
+ end
28
+
29
+ def self.config_for(platform, notification)
30
+ platform_config = Rails.application.config_for(:push)[platform.to_sym]
31
+ raise "ActionPushNative: '#{platform}' platform is not configured" unless platform_config.present?
32
+
33
+ if notification.application.present?
34
+ notification_config = platform_config.fetch(notification.application.to_sym, {})
35
+ platform_config.fetch(:application, {}).merge(notification_config)
36
+ else
37
+ platform_config
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActionPushNative::InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def copy_files
7
+ template "config/push.yml"
8
+ template "app/models/application_push_notification.rb"
9
+ template "app/models/application_push_device.rb"
10
+ template "app/jobs/application_push_notification_job.rb"
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ class ApplicationPushNotificationJob < ActionPushNative::NotificationJob
2
+ # Enable logging job arguments (default: false)
3
+ # self.log_arguments = true
4
+
5
+ # Report job retries via the `Rails.error` reporter (default: false)
6
+ # self.report_job_retries = true
7
+ end
@@ -0,0 +1,4 @@
1
+ class ApplicationPushDevice < ActionPushNative::Device
2
+ # Customize TokenError handling (default: destroy!)
3
+ # rescue_from (ActionPushNative::TokenError) { Rails.logger.error("Device #{id} token is invalid") }
4
+ end
@@ -0,0 +1,12 @@
1
+ class ApplicationPushNotification < ActionPushNative::Notification
2
+ # Set a custom job queue_name
3
+ # queue_as :realtime
4
+
5
+ # Controls whether push notifications are enabled (default: !Rails.env.test?)
6
+ # self.enabled = Rails.env.production?
7
+
8
+ # Define a custom callback to modify or abort the notification before it is sent
9
+ # before_delivery do |notification|
10
+ # throw :abort if Notification.find(notification.context[:notification_id]).expired?
11
+ # end
12
+ end
@@ -0,0 +1,34 @@
1
+ shared:
2
+ apple:
3
+ # Token auth params
4
+ # See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
5
+ key_id: your_key_id
6
+ encryption_key: your_apple_encryption_key
7
+
8
+ team_id: your_apple_team_id
9
+ # Your identifier found on https://developer.apple.com/account/resources/identifiers/list
10
+ topic: your.bundle.identifier
11
+
12
+ # Set this to the number of threads used to process notifications (default: 5).
13
+ # When the pool size is too small a ConnectionPool::TimeoutError error will be raised.
14
+ # connection_pool_size: 5
15
+
16
+ # Change the request timeout (default: 30).
17
+ # request_timeout: 60
18
+
19
+ # Decide when to connect to APNs development server.
20
+ # Please note that anything built directly from Xcode and loaded on your phone will have
21
+ # the app generate DEVELOPMENT tokens, while everything else (TestFlight, Apple Store, ...)
22
+ # will be considered as PRODUCTION environment.
23
+ # connect_to_development_server: <%# Rails.env.development? %>
24
+
25
+ google:
26
+ # Your Firebase project service account credentials
27
+ # See https://firebase.google.com/docs/cloud-messaging/auth-server
28
+ encryption_key: your_service_account_json_file
29
+
30
+ # Firebase project_id
31
+ project_id: your_project_id
32
+
33
+ # Change the request timeout (default: 15).
34
+ # request_timeout: 30
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: action_push_native
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jacopo Beschi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activejob
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '8.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '8.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: apnotic
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.7'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.7'
68
+ - !ruby/object:Gem::Dependency
69
+ name: googleauth
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.14'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.14'
82
+ - !ruby/object:Gem::Dependency
83
+ name: net-http
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.6'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.6'
96
+ description: Send push notifications to mobile apps
97
+ email:
98
+ - jacopo@37signals.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - MIT-LICENSE
104
+ - README.md
105
+ - Rakefile
106
+ - app/jobs/action_push_native/notification_job.rb
107
+ - app/models/action_push_native/device.rb
108
+ - db/migrate/20250610075650_create_action_push_native_device.rb
109
+ - lib/action_push_native.rb
110
+ - lib/action_push_native/configured_notification.rb
111
+ - lib/action_push_native/engine.rb
112
+ - lib/action_push_native/errors.rb
113
+ - lib/action_push_native/notification.rb
114
+ - lib/action_push_native/service/apns.rb
115
+ - lib/action_push_native/service/fcm.rb
116
+ - lib/action_push_native/version.rb
117
+ - lib/generators/action_push_native/install/install_generator.rb
118
+ - lib/generators/action_push_native/install/templates/app/jobs/application_push_notification_job.rb.tt
119
+ - lib/generators/action_push_native/install/templates/app/models/application_push_device.rb.tt
120
+ - lib/generators/action_push_native/install/templates/app/models/application_push_notification.rb.tt
121
+ - lib/generators/action_push_native/install/templates/config/push.yml.tt
122
+ homepage: https://github.com/basecamp/action_push_native
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ homepage_uri: https://github.com/basecamp/action_push_native
127
+ source_code_uri: https://github.com/basecamp/action_push_native
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 3.2.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.6.7
143
+ specification_version: 4
144
+ summary: Send push notifications to mobile apps
145
+ test_files: []