├── test ├── fixtures │ ├── base_notifier │ │ ├── with_apn_template.json+apn.jbuilder │ │ ├── with_fcm_template.json+fcm.jbuilder │ │ ├── missing_apn_template.json+fcm.jbuilder │ │ ├── missing_fcm_template.json+apn.jbuilder │ │ ├── with_nil_as_return_value.json+apn.jbuilder │ │ ├── welcome.json+fcm.jbuilder │ │ └── welcome.json+apn.jbuilder │ ├── url_test_notifier │ │ └── url.json+apn.jbuilder │ ├── notifier_with_rescue_handler │ │ ├── apn.json+apn.jbuilder │ │ └── fcm.json+fcm.jbuilder │ └── maintainer_notifier │ │ ├── build_result.json+apn.jbuilder │ │ ├── build_result.json+fcm.jbuilder │ │ └── build_result_with_custom_apn_config.json+apn.jbuilder ├── integration │ ├── adapters │ │ ├── andpush_test.rb │ │ ├── fcm_adapter_test.rb │ │ ├── houston_test.rb │ │ ├── lowdown_test.rb │ │ └── apnotic_test.rb │ ├── test_helper.rb │ ├── apn_tcp_test_cases.rb │ ├── apn_http2_test_cases.rb │ └── fcm_test_cases.rb ├── notifiers │ ├── notifier_with_rescue_handler.rb │ ├── maintainer_notifier.rb │ ├── base_notifier.rb │ └── delayed_notifier.rb ├── backport │ └── method_call_assertions.rb ├── generator_test.rb ├── railtie_test.rb ├── test_helper.rb ├── log_subscriber_test.rb ├── platforms_test.rb ├── url_helper_test.rb ├── isolated_test_helper.rb ├── notification_delivery_test.rb └── base_test.rb ├── lib ├── pushing │ ├── version.rb │ ├── template_handlers.rb │ ├── template_handlers │ │ └── jbuilder_handler.rb │ ├── rescuable.rb │ ├── delivery_job.rb │ ├── adapters │ │ ├── test_adapter.rb │ │ ├── apn │ │ │ ├── houston_adapter.rb │ │ │ ├── lowdown_adapter.rb │ │ │ └── apnotic_adapter.rb │ │ └── fcm │ │ │ ├── andpush_adapter.rb │ │ │ └── fcm_gem_adapter.rb │ ├── log_subscriber.rb │ ├── adapters.rb │ ├── railtie.rb │ ├── notification_delivery.rb │ ├── platforms.rb │ └── base.rb ├── generators │ └── pushing │ │ ├── templates │ │ ├── application_notifier.rb │ │ ├── notifier.rb │ │ ├── template.json+apn.jbuilder │ │ ├── initializer.rb │ │ └── template.json+fcm.jbuilder │ │ ├── USAGE │ │ └── notifier_generator.rb └── pushing.rb ├── certs ├── apns_example_production.pem.enc └── apns_auth_key_for_jwt_auth.p8.enc ├── bin ├── setup └── console ├── .gitignore ├── Gemfile ├── gemfiles ├── rails_51.gemfile ├── rails_52.gemfile ├── rails_edge.gemfile ├── rails_42.gemfile └── rails_50.gemfile ├── Rakefile ├── Appraisals ├── CHANGELOG.md ├── LICENSE.txt ├── pushing.gemspec ├── .travis.yml ├── CODE_OF_CONDUCT.md └── README.md /test/fixtures/base_notifier/with_apn_template.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/base_notifier/with_fcm_template.json+fcm.jbuilder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/base_notifier/missing_apn_template.json+fcm.jbuilder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/base_notifier/missing_fcm_template.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/pushing/version.rb: -------------------------------------------------------------------------------- 1 | module Pushing 2 | VERSION = "0.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /certs/apns_example_production.pem.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/pushing/master/certs/apns_example_production.pem.enc -------------------------------------------------------------------------------- /test/fixtures/base_notifier/with_nil_as_return_value.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | json.aps do 2 | json.alert "New message!" 3 | end 4 | -------------------------------------------------------------------------------- /certs/apns_auth_key_for_jwt_auth.p8.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/pushing/master/certs/apns_auth_key_for_jwt_auth.p8.enc -------------------------------------------------------------------------------- /test/fixtures/base_notifier/welcome.json+fcm.jbuilder: -------------------------------------------------------------------------------- 1 | json.data do 2 | json.message 'Hello FCM!' 3 | end 4 | json.to 'device-token' 5 | -------------------------------------------------------------------------------- /lib/generators/pushing/templates/application_notifier.rb: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class ApplicationNotifier < Pushing::Base 3 | end 4 | <% end %> 5 | -------------------------------------------------------------------------------- /test/fixtures/base_notifier/welcome.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | json.aps do 2 | json.alert "New message!" 3 | json.badge 9 4 | json.sound "bingbong.aiff" 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | gemfiles/*.lock 11 | certs/apns_example_production.pem 12 | gemfiles/.bundle/ 13 | certs/apns_auth_key_for_jwt_auth.p8 14 | -------------------------------------------------------------------------------- /lib/generators/pushing/templates/notifier.rb: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class <%= class_name %>Notifier < ApplicationNotifier 3 | <% actions.each do |action| -%> 4 | def <%= action %> 5 | @greeting = "Hi" 6 | 7 | push apn: "device-token", fcm: true 8 | end 9 | <% end -%> 10 | end 11 | <% end -%> 12 | -------------------------------------------------------------------------------- /test/integration/adapters/andpush_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration/test_helper' 2 | require 'integration/fcm_test_cases' 3 | 4 | class AndpushIntegrationTest < ActiveSupport::TestCase 5 | include FcmTestCases 6 | 7 | setup do 8 | Pushing.config.fcm.adapter = :andpush 9 | end 10 | 11 | private 12 | 13 | def adapter 14 | 'andpush' 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /test/integration/adapters/fcm_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration/test_helper' 2 | require 'integration/fcm_test_cases' 3 | 4 | class FcmAdapterIntegrationTest < ActiveSupport::TestCase 5 | include FcmTestCases 6 | 7 | setup do 8 | Pushing.config.fcm.adapter = :fcm_gem 9 | end 10 | 11 | private 12 | 13 | def adapter 14 | 'fcm' 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /test/fixtures/url_test_notifier/url.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | json.aps do 2 | json.alert "I'm sending you URLs!" 3 | end 4 | 5 | json.url_from_action @url_for 6 | json.url_in_view url_for(@options) 7 | 8 | json.welcome_url_from_action @welcome_url 9 | json.welcome_url_in_view welcome_url 10 | 11 | json.asset_url asset_url('puppy.jpeg') 12 | json.image_url image_url('puppy.jpeg') 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in pushing.gemspec 4 | gemspec 5 | 6 | # APNs 7 | gem 'houston', require: false 8 | gem 'apnotic', '>= 1.2.0', require: false 9 | gem 'lowdown', require: false 10 | 11 | # FCM 12 | gem 'andpush', require: false 13 | gem 'fcm', require: false 14 | 15 | # Debugging 16 | gem 'pry' 17 | gem 'pry-byebug', platforms: :mri 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "pushing" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /test/fixtures/notifier_with_rescue_handler/apn.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | json.aps do 2 | json.alert do 3 | json.title "How Much Snow Has Fallen" 4 | json.body "The New York City region was predicted to receive as much as 20 inches of snow." 5 | end 6 | 7 | json.badge 9 8 | json.sound "bingbong.aiff" 9 | end 10 | 11 | json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.' 12 | -------------------------------------------------------------------------------- /test/fixtures/notifier_with_rescue_handler/fcm.json+fcm.jbuilder: -------------------------------------------------------------------------------- 1 | json.to ENV.fetch("FCM_TEST_REGISTRATION_TOKEN") 2 | 3 | json.notification do 4 | json.title "How Much Snow Has Fallen" 5 | json.body "The New York City region was predicted to receive as much as 20 inches of snow." 6 | end 7 | 8 | json.data do 9 | json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.' 10 | end 11 | -------------------------------------------------------------------------------- /lib/pushing/template_handlers.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Pushing 4 | module TemplateHandlers 5 | extend ActiveSupport::Autoload 6 | 7 | autoload :JbuilderHandler 8 | 9 | def self.lookup(template) 10 | const_get("#{template.to_s.camelize}Handler") 11 | rescue NameError 12 | raise NotImplementedError.new("The template engine `#{template}' is not yet supported.") 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/maintainer_notifier/build_result.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | json.aps do 2 | json.alert do 3 | json.title "Build was successfully run" 4 | json.body "The tests for #{@ruby_version}, Rails #{@rails_version}, adapter #{@adapter} has passed." 5 | end 6 | 7 | json.badge 1 8 | json.sound "bingbong.aiff" 9 | end 10 | 11 | # json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.' 12 | 13 | -------------------------------------------------------------------------------- /test/fixtures/maintainer_notifier/build_result.json+fcm.jbuilder: -------------------------------------------------------------------------------- 1 | json.to ENV.fetch("FCM_TEST_REGISTRATION_TOKEN") 2 | json.dry_run true 3 | 4 | json.notification do 5 | json.title "Build was successfully run" 6 | json.body "The tests for #{@ruby_version}, Rails #{@rails_version} andadapter #{@adapter} has passed." 7 | end 8 | 9 | # json.data do 10 | # json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.' 11 | # end 12 | -------------------------------------------------------------------------------- /test/fixtures/maintainer_notifier/build_result_with_custom_apn_config.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | json.aps do 2 | json.alert do 3 | json.title "Build was successfully run" 4 | json.body "The tests for #{@ruby_version}, Rails #{@rails_version}, adapter #{@adapter} has passed." 5 | end 6 | 7 | json.badge 1 8 | json.sound "bingbong.aiff" 9 | end 10 | 11 | # json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.' 12 | 13 | -------------------------------------------------------------------------------- /test/integration/adapters/houston_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration/test_helper' 2 | require 'integration/apn_tcp_test_cases' 3 | 4 | class HoustonIntegrationTest < ActiveSupport::TestCase 5 | include ApnTcpTestCases 6 | 7 | setup do 8 | Pushing.config.apn.adapter = :houston 9 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH')) 10 | end 11 | 12 | private 13 | 14 | def adapter 15 | 'houston' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /gemfiles/rails_51.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "houston", require: false 6 | gem "apnotic", ">= 1.2.0", require: false 7 | gem "lowdown", require: false 8 | gem "andpush", require: false 9 | gem "fcm", require: false 10 | gem "pry" 11 | gem "pry-byebug", platforms: :mri 12 | gem "railties", "~> 5.1.0" 13 | gem "actionpack", "~> 5.1.0" 14 | gem "actionview", "~> 5.1.0" 15 | gem "activejob", "~> 5.1.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_52.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "houston", require: false 6 | gem "apnotic", ">= 1.2.0", require: false 7 | gem "lowdown", require: false 8 | gem "andpush", require: false 9 | gem "fcm", require: false 10 | gem "pry" 11 | gem "pry-byebug", platforms: :mri 12 | gem "railties", "~> 5.2.0.rc1" 13 | gem "actionpack", "~> 5.2.0.rc1" 14 | gem "actionview", "~> 5.2.0.rc1" 15 | gem "activejob", "~> 5.2.0.rc1" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_edge.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | git "git://github.com/rails/rails.git" do 6 | gem "railties" 7 | gem "actionpack" 8 | gem "actionview" 9 | gem "activejob" 10 | end 11 | 12 | gem "houston", require: false 13 | gem "apnotic", ">= 1.2.0", require: false 14 | gem "lowdown", require: false 15 | gem "andpush", require: false 16 | gem "fcm", require: false 17 | gem "pry" 18 | gem "pry-byebug", platforms: :mri 19 | 20 | gemspec path: "../" 21 | -------------------------------------------------------------------------------- /lib/pushing/template_handlers/jbuilder_handler.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require 'jbuilder/jbuilder_template' 4 | 5 | module Pushing 6 | module TemplateHandlers 7 | class JbuilderHandler < ::JbuilderHandler 8 | def self.call(*) 9 | super.gsub("json.target!", "json.attributes!").gsub("new(self)", "new(self, key_formatter: ::Pushing::TemplateHandlers::JbuilderHandler)") 10 | end 11 | 12 | def self.format(key) 13 | key 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /gemfiles/rails_42.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "houston", require: false 6 | gem "apnotic", ">= 1.2.0", require: false 7 | gem "lowdown", require: false 8 | gem "andpush", require: false 9 | gem "fcm", require: false 10 | gem "pry" 11 | gem "pry-byebug", platforms: :mri 12 | gem "rails", "~> 4.2.0" 13 | gem "actionpack", "~> 4.2.0" 14 | gem "actionview", "~> 4.2.0" 15 | gem "activejob", "~> 4.2.0" 16 | gem "minitest", "5.10.3" 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /gemfiles/rails_50.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "houston", require: false 6 | gem "apnotic", ">= 1.2.0", require: false 7 | gem "lowdown", require: false 8 | gem "andpush", require: false 9 | gem "fcm", require: false 10 | gem "pry" 11 | gem "pry-byebug", platforms: :mri 12 | gem "railties", "~> 5.0.0" 13 | gem "actionpack", "~> 5.0.0" 14 | gem "actionview", "~> 5.0.0" 15 | gem "activejob", "~> 5.0.0" 16 | gem "minitest", "5.10.3" 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /test/integration/adapters/lowdown_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration/test_helper' 2 | require 'integration/apn_http2_test_cases' 3 | 4 | class LowdownTest < ActiveSupport::TestCase 5 | include ApnHttp2TestCases 6 | 7 | setup do 8 | Pushing.config.apn.adapter = :lowdown 9 | Pushing.config.apn.connection_scheme = :certificate 10 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH')) 11 | end 12 | 13 | private 14 | 15 | def adapter 16 | 'lowdown' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/notifiers/notifier_with_rescue_handler.rb: -------------------------------------------------------------------------------- 1 | class NotifierWithRescueHandler < Pushing::Base 2 | cattr_accessor :last_response_from_apn 3 | cattr_accessor :last_response_from_fcm 4 | 5 | rescue_from Pushing::ApnDeliveryError do |exception| 6 | self.class.last_response_from_apn = exception.response 7 | end 8 | 9 | rescue_from Pushing::FcmDeliveryError do |exception| 10 | self.class.last_response_from_fcm = exception.response 11 | end 12 | 13 | def fcm 14 | push fcm: true 15 | end 16 | 17 | def apn 18 | push apn: 'invalid-token' 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/pushing.rb: -------------------------------------------------------------------------------- 1 | require "active_support/dependencies/autoload" 2 | require "pushing/version" 3 | 4 | module Pushing 5 | extend ::ActiveSupport::Autoload 6 | 7 | autoload :Adapters 8 | autoload :Base 9 | autoload :DeliveryJob 10 | autoload :NotificationDelivery 11 | autoload :Platforms 12 | 13 | def self.configure(&block) 14 | Base.configure(&block) 15 | Base.config.each { |k, v| Base.public_send("#{k}=", v) if Base.respond_to?("#{k}=") } 16 | end 17 | 18 | def self.config 19 | Base.config 20 | end 21 | end 22 | 23 | if defined?(Rails) 24 | require 'pushing/railtie' 25 | end 26 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList['test/**/*_test.rb'] - FileList['test/integration/**/*'] - ["test/railtie_test.rb"] 8 | end 9 | 10 | Rake::TestTask.new('test:integration') do |t| 11 | t.libs << "test" 12 | t.libs << "lib" 13 | t.test_files = FileList['test/integration/**/*_test.rb'] 14 | end 15 | 16 | Rake::TestTask.new('test:isolated') do |t| 17 | t.libs << "test" 18 | t.libs << "lib" 19 | t.test_files = ["test/railtie_test.rb"] 20 | end 21 | 22 | task default: [:test, 'test:isolated'] 23 | -------------------------------------------------------------------------------- /lib/generators/pushing/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Stubs out a new notifier and its views. Passes the notifier name, either 3 | CamelCased or under_scored, and an optional list of push notifications 4 | as arguments. 5 | 6 | This generates a notifier class in app/notifiers. 7 | 8 | Example: 9 | rails generate pushing:notifier TweetNotifier new_mention_in_tweet 10 | 11 | creates a TweetNotifier class and views: 12 | Mailer: app/notifiers/tweet_notifier.rb 13 | Views: app/views/tweet_notifier/new_mention_in_tweet.json+apn.jbuilder 14 | app/views/tweet_notifier/new_mention_in_tweet.json+fcm.jbuilder 15 | -------------------------------------------------------------------------------- /lib/pushing/rescuable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/rescuable' 2 | 3 | module Pushing #:nodoc: 4 | module Rescuable 5 | extend ActiveSupport::Concern 6 | include ActiveSupport::Rescuable 7 | 8 | class_methods do 9 | def handle_exception(exception) #:nodoc: 10 | rescue_with_handler(exception) || raise(exception) 11 | end 12 | end 13 | 14 | def handle_exceptions #:nodoc: 15 | yield 16 | rescue => exception 17 | rescue_with_handler(exception) || raise 18 | end 19 | 20 | private 21 | 22 | def process(*) 23 | handle_exceptions do 24 | super 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/notifiers/maintainer_notifier.rb: -------------------------------------------------------------------------------- 1 | class MaintainerNotifier < Pushing::Base 2 | def build_result(adapter, apn: false, fcm: false) 3 | @adapter = adapter 4 | @ruby_version = RUBY_DESCRIPTION 5 | @rails_version = Rails::VERSION::STRING 6 | 7 | push apn: (apn == true ? ENV.fetch('APN_TEST_DEVICE_TOKEN') : apn), fcm: fcm 8 | end 9 | 10 | def build_result_with_custom_apn_config(adapter, env, headers) 11 | @adapter = adapter 12 | @ruby_version = RUBY_DESCRIPTION 13 | @rails_version = Rails::VERSION::STRING 14 | 15 | push apn: { device_token: ENV.fetch('APN_TEST_DEVICE_TOKEN'), environment: env, headers: headers } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/notifiers/base_notifier.rb: -------------------------------------------------------------------------------- 1 | class BaseNotifier < Pushing::Base 2 | def welcome(hash = {}) 3 | push apn: 'device-token', fcm: true 4 | end 5 | 6 | def missing_apn_template 7 | push apn: 'device-token' 8 | end 9 | 10 | def missing_fcm_template 11 | push fcm: true 12 | end 13 | 14 | def with_apn_template 15 | push apn: 'device-token' 16 | end 17 | 18 | def with_no_apn_device_token 19 | push apn: { device_token: nil } 20 | end 21 | 22 | def with_fcm_template 23 | push fcm: true 24 | end 25 | 26 | def without_push_call 27 | end 28 | 29 | def with_nil_as_return_value 30 | push apn: 'device-token' 31 | nil 32 | end 33 | end 34 | 35 | -------------------------------------------------------------------------------- /test/integration/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'webmock/minitest' 3 | 4 | require 'notifiers/maintainer_notifier' 5 | require 'notifiers/notifier_with_rescue_handler' 6 | 7 | WebMock.allow_net_connect! 8 | 9 | Pushing::Base.logger = Logger.new(STDOUT) 10 | Pushing.configure do |config| 11 | config.fcm.server_key = ENV.fetch('FCM_TEST_SERVER_KEY') 12 | 13 | config.apn.environment = :development 14 | config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH')) 15 | config.apn.certificate_password = ENV.fetch('APN_TEST_CERTIFICATE_PASSWORD') 16 | config.apn.default_headers = { 17 | apns_topic: ENV.fetch('APN_TEST_TOPIC') 18 | } 19 | end 20 | -------------------------------------------------------------------------------- /test/notifiers/delayed_notifier.rb: -------------------------------------------------------------------------------- 1 | require "active_job/arguments" 2 | 3 | class DelayedNotifierError < StandardError; end 4 | 5 | class DelayedNotifier < Pushing::Base 6 | cattr_accessor :last_error 7 | cattr_accessor :last_rescue_from_instance 8 | 9 | if ActiveSupport::VERSION::MAJOR > 4 10 | rescue_from DelayedNotifierError do |error| 11 | @@last_error = error 12 | @@last_rescue_from_instance = self 13 | end 14 | 15 | rescue_from ActiveJob::DeserializationError do |error| 16 | @@last_error = error 17 | @@last_rescue_from_instance = self 18 | end 19 | end 20 | 21 | def test_message(*) 22 | push fcm: true 23 | end 24 | 25 | def test_raise(klass_name) 26 | raise klass_name.constantize, "boom" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/pushing/delivery_job.rb: -------------------------------------------------------------------------------- 1 | require 'active_job' 2 | 3 | module Pushing 4 | class DeliveryJob < ActiveJob::Base # :nodoc: 5 | queue_as { Pushing::Base.deliver_later_queue_name } 6 | 7 | if ActiveSupport::VERSION::MAJOR > 4 8 | rescue_from StandardError, with: :handle_exception_with_notifier_class 9 | end 10 | 11 | def perform(notifier, mail_method, delivery_method, *args) #:nodoc: 12 | notifier.constantize.public_send(mail_method, *args).send(delivery_method) 13 | end 14 | 15 | private 16 | 17 | def notifier_class 18 | if notifier = Array(@serialized_arguments).first || Array(arguments).first 19 | notifier.constantize 20 | end 21 | end 22 | 23 | def handle_exception_with_notifier_class(exception) 24 | if klass = notifier_class 25 | klass.handle_exception exception 26 | else 27 | raise exception 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/pushing/adapters/test_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/module/attribute_accessors' 2 | 3 | module Pushing 4 | module Adapters 5 | class TestAdapter 6 | class Deliveries 7 | include Enumerable 8 | 9 | def initialize 10 | @deliveries = [] 11 | end 12 | 13 | delegate :each, :empty?, :clear, :<<, :length, :size, to: :@deliveries 14 | 15 | def apn 16 | select {|delivery| delivery.is_a?(Platforms::ApnPayload) } 17 | end 18 | 19 | def fcm 20 | select {|delivery| delivery.is_a?(Platforms::FcmPayload) } 21 | end 22 | end 23 | 24 | private_constant :Deliveries 25 | cattr_accessor :deliveries 26 | self.deliveries = Deliveries.new 27 | 28 | def initialize(*) 29 | end 30 | 31 | def push!(notification) 32 | self.class.deliveries << notification if notification 33 | notification 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails_edge" do 2 | git 'git://github.com/rails/rails.git' do 3 | gem "railties" 4 | gem "actionpack" 5 | gem "actionview" 6 | gem "activejob" 7 | end 8 | end 9 | 10 | appraise "rails_52" do 11 | gem "railties", '~> 5.2.0.rc1' 12 | gem "actionpack", '~> 5.2.0.rc1' 13 | gem "actionview", '~> 5.2.0.rc1' 14 | gem "activejob", '~> 5.2.0.rc1' 15 | end 16 | 17 | appraise "rails_51" do 18 | gem "railties", '~> 5.1.0' 19 | gem "actionpack", '~> 5.1.0' 20 | gem "actionview", '~> 5.1.0' 21 | gem "activejob", '~> 5.1.0' 22 | end 23 | 24 | appraise "rails_50" do 25 | gem "railties", '~> 5.0.0' 26 | gem "actionpack", '~> 5.0.0' 27 | gem "actionview", '~> 5.0.0' 28 | gem "activejob", '~> 5.0.0' 29 | gem "minitest", '5.10.3' 30 | end 31 | 32 | appraise "rails_42" do 33 | gem "rails", '~> 4.2.0' 34 | gem "actionpack", '~> 4.2.0' 35 | gem "actionview", '~> 4.2.0' 36 | gem "activejob", '~> 4.2.0' 37 | gem "minitest", '5.10.3' 38 | end 39 | -------------------------------------------------------------------------------- /test/integration/apn_tcp_test_cases.rb: -------------------------------------------------------------------------------- 1 | module ApnTcpTestCases 2 | def setup 3 | super 4 | Pushing::Adapters.const_get(:ADAPTER_INSTANCES).clear 5 | end 6 | 7 | def test_actually_push_notification 8 | assert_nothing_raised do 9 | MaintainerNotifier.build_result(adapter, apn: true).deliver_now! 10 | end 11 | end 12 | 13 | def test_actually_push_notification_with_custom_config 14 | # Set the wrong topic/environment to make sure you can override these on the fly 15 | Pushing.config.apn.environment = :production 16 | Pushing.config.apn.default_headers = { 17 | apns_topic: 'wrong.topicname.com' 18 | } 19 | 20 | assert_nothing_raised do 21 | MaintainerNotifier.build_result_with_custom_apn_config(adapter, :development, {}).deliver_now! 22 | end 23 | ensure 24 | Pushing.config.apn.environment = :development 25 | Pushing.config.apn.default_headers = { 26 | apns_topic: ENV.fetch('APN_TEST_TOPIC') 27 | } 28 | end 29 | 30 | def adapter 31 | raise NotImplementedError 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v0.2.0](https://github.com/yuki24/pushing/tree/v0.2.0) 2 | 3 | _released at 2018-03-11 00:38:57 UTC_ 4 | 5 | #### New Features 6 | 7 | - Add support for Rails 5.2 ([9345a53](https://github.com/yuki24/pushing/commit/9345a53e12745e0d4fbaf83a103fe9b1f008fe35)) 8 | - Log APNs environment ([#16](https://github.com/yuki24/pushing/issues/16), [88c46ce](https://github.com/yuki24/pushing/commit/88c46ce6937f12adf5296049423f419c348106f1), [27dd2b6](https://github.com/yuki24/pushing/commit/27dd2b65119f53fcceaf3ae6688152d9aa6c36a8)) 9 | - Add support for APNs' token-based authentication ([6f2a5dc](https://github.com/yuki24/pushing/commit/6f2a5dc1ee885ba76a687f361e9c2fa1bb8cdce8)) 10 | - Add the ability to call URL helpers in notifiers ([#9](https://github.com/yuki24/pushing/issues/9), [3fecf17](https://github.com/yuki24/pushing/commit/3fecf175b8f3a5aad6bc93698acd2e6d1cc47a5f)) 11 | 12 | ## [v0.1.0: First release](https://github.com/yuki24/pushing/tree/v0.1.0) 13 | 14 | _released at 2018-03-08 01:36:18 UTC_ 15 | 16 | -------------------------------------------------------------------------------- /test/integration/adapters/apnotic_test.rb: -------------------------------------------------------------------------------- 1 | require 'integration/test_helper' 2 | require 'integration/apn_http2_test_cases' 3 | 4 | class ApnoticIntegrationForCertificateConnectionTest < ActiveSupport::TestCase 5 | include ApnHttp2TestCases 6 | 7 | setup do 8 | Pushing.config.apn.adapter = :apnotic 9 | Pushing.config.apn.connection_scheme = :certificate 10 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH')) 11 | end 12 | 13 | private 14 | 15 | def adapter 16 | 'apnotic' 17 | end 18 | end 19 | 20 | class ApnoticIntegrationTestForJwtConnection < ActiveSupport::TestCase 21 | include ApnHttp2TestCases 22 | 23 | setup do 24 | Pushing.config.apn.adapter = :apnotic 25 | Pushing.config.apn.connection_scheme = :token 26 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_AUTH_KEY_PATH')) 27 | Pushing.config.apn.key_id = ENV['APN_TEST_KEY_ID'] 28 | Pushing.config.apn.team_id = ENV['APN_TEST_TEAM_ID'] 29 | end 30 | 31 | private 32 | 33 | def adapter 34 | 'apnotic' 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Yuki Nishijima 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/pushing/adapters/apn/houston_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'houston' 2 | 3 | module Pushing 4 | module Adapters 5 | class HoustonAdapter 6 | attr_reader :certificate_path, :client 7 | 8 | def initialize(apn_settings) 9 | @certificate_path = apn_settings.certificate_path 10 | 11 | @client = { 12 | production: Houston::Client.production, 13 | development: Houston::Client.development 14 | } 15 | @client[:production].certificate = @client[:development].certificate = File.read(certificate_path) 16 | end 17 | 18 | def push!(notification) 19 | payload = notification.payload.dup 20 | aps = payload.delete(:aps) 21 | aps[:device] = notification.device_token 22 | 23 | houston_notification = Houston::Notification.new(payload.merge(aps)) 24 | client[notification.environment].push(houston_notification) 25 | rescue => cause 26 | error = Pushing::ApnDeliveryError.new("Error while trying to send push notification: #{cause.message}", nil, notification) 27 | 28 | raise error, error.message, cause.backtrace 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/backport/method_call_assertions.rb: -------------------------------------------------------------------------------- 1 | require "minitest/mock" 2 | 3 | module MethodCallAssertions # :nodoc: 4 | private 5 | def assert_called(object, method_name, message = nil, times: 1, returns: nil) 6 | times_called = 0 7 | 8 | object.stub(method_name, proc { times_called += 1; returns }) { yield } 9 | 10 | error = "Expected #{method_name} to be called #{times} times, " \ 11 | "but was called #{times_called} times" 12 | error = "#{message}.\n#{error}" if message 13 | assert_equal times, times_called, error 14 | end 15 | 16 | def assert_called_with(object, method_name, args = [], returns: nil) 17 | mock = Minitest::Mock.new 18 | 19 | if args.all? { |arg| arg.is_a?(Array) } 20 | args.each { |arg| mock.expect(:call, returns, arg) } 21 | else 22 | mock.expect(:call, returns, args) 23 | end 24 | 25 | object.stub(method_name, mock) { yield } 26 | 27 | mock.verify 28 | end 29 | 30 | def assert_not_called(object, method_name, message = nil, &block) 31 | assert_called(object, method_name, message, times: 0, &block) 32 | end 33 | 34 | def stub_any_instance(klass, instance: klass.new) 35 | klass.stub(:new, instance) { yield instance } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /pushing.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'pushing/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "pushing" 8 | spec.version = Pushing::VERSION 9 | spec.authors = ["Yuki Nishijima"] 10 | spec.email = ["yk.nishijima@gmail.com"] 11 | spec.summary = %q{Push notification framework that does not hurt} 12 | spec.description = %q{Finally, push notification framework that does not hurt. Currently supports Android (FCM) and iOS (APNs)} 13 | spec.homepage = "https://github.com/yuki24/pushing" 14 | spec.license = "MIT" 15 | spec.files = `git ls-files -z`.split("\x0").reject {|f| f.match(%r{^(test)/}) } 16 | spec.require_paths = ["lib"] 17 | 18 | spec.add_dependency "actionpack", ">= 4.2.0" 19 | spec.add_dependency "actionview", ">= 4.2.0" 20 | spec.add_dependency "activejob", ">= 4.2.0" 21 | 22 | spec.add_development_dependency "bundler" 23 | spec.add_development_dependency "rake" 24 | spec.add_development_dependency "minitest" 25 | spec.add_development_dependency "appraisal" 26 | spec.add_development_dependency "jbuilder" 27 | spec.add_development_dependency "webmock" 28 | end 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | nguage: ruby 2 | script: bundle exec rake test test:isolated test:integration 3 | cache: bundler 4 | sudo: false 5 | 6 | before_install: 7 | - gem install bundler 8 | - openssl aes-256-cbc -K $encrypted_86ed69f44076_key -iv $encrypted_86ed69f44076_iv -in ./certs/apns_auth_key_for_jwt_auth.p8.enc -out certs/apns_auth_key_for_jwt_auth.p8 -d 9 | - openssl aes-256-cbc -K $encrypted_86ed69f44076_key -iv $encrypted_86ed69f44076_iv -in ./certs/apns_example_production.pem.enc -out certs/apns_example_production.pem -d 10 | 11 | rvm: 12 | - 2.2.10 13 | - 2.3.8 14 | - 2.4.5 15 | - 2.5.3 16 | - 2.6.1 17 | - ruby-head 18 | - jruby-9.2.6.0 19 | - jruby-head 20 | 21 | gemfile: 22 | - gemfiles/rails_42.gemfile 23 | - gemfiles/rails_50.gemfile 24 | - gemfiles/rails_51.gemfile 25 | - gemfiles/rails_52.gemfile 26 | - gemfiles/rails_edge.gemfile 27 | 28 | matrix: 29 | allow_failures: 30 | - rvm: ruby-head 31 | - rvm: jruby-9.2.6.0 32 | - rvm: jruby-head 33 | - gemfile: gemfiles/rails_edge.gemfile 34 | exclude: 35 | - rvm: 2.4.5 36 | gemfile: gemfiles/rails_edge.gemfile 37 | - rvm: 2.3.8 38 | gemfile: gemfiles/rails_edge.gemfile 39 | - rvm: 2.2.10 40 | gemfile: gemfiles/rails_52.gemfile 41 | - rvm: 2.2.10 42 | gemfile: gemfiles/rails_edge.gemfile 43 | -------------------------------------------------------------------------------- /lib/pushing/adapters/fcm/andpush_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require 'andpush' 4 | require 'active_support/core_ext/hash/transform_values' 5 | 6 | module Pushing 7 | module Adapters 8 | class AndpushAdapter 9 | attr_reader :server_key 10 | 11 | def initialize(fcm_settings) 12 | @server_key = fcm_settings.server_key 13 | end 14 | 15 | def push!(notification) 16 | FcmResponse.new(client.push(notification.payload)) 17 | rescue => e 18 | response = e.respond_to?(:response) ? FcmResponse.new(e.response) : nil 19 | error = Pushing::FcmDeliveryError.new("Error while trying to send push notification: #{e.message}", response, notification) 20 | 21 | raise error, error.message, e.backtrace 22 | end 23 | 24 | private 25 | 26 | def client 27 | @client ||= Andpush.build(server_key) 28 | end 29 | 30 | class FcmResponse < SimpleDelegator 31 | def json 32 | @json ||= __getobj__.json 33 | end 34 | 35 | def code 36 | __getobj__.code.to_i 37 | end 38 | 39 | def headers 40 | __getobj__.headers.transform_values {|value| value.join(", ") } 41 | end 42 | end 43 | 44 | private_constant :FcmResponse 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/generator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "rails/generators/test_case" 3 | 4 | require 'generators/pushing/notifier_generator' 5 | 6 | class NotifierGeneratorTest < Rails::Generators::TestCase 7 | tests Pushing::NotifierGenerator 8 | arguments %w(TweetNotifier new_mention_in_tweet) 9 | destination File.join(File.expand_path('../', File.dirname(__FILE__)), "tmp") 10 | setup :prepare_destination 11 | 12 | test "notifier skeleton is created" do 13 | run_generator 14 | 15 | assert_file "config/initializers/pushing.rb" do |notifier| 16 | assert_match(/Pushing.configure do |config|/, notifier) 17 | end 18 | 19 | assert_file "app/notifiers/application_notifier.rb" do |notifier| 20 | assert_match(/class ApplicationNotifier < Pushing::Base/, notifier) 21 | end 22 | 23 | assert_file "app/notifiers/tweet_notifier.rb" do |notifier| 24 | assert_match(/class TweetNotifier < ApplicationNotifier/, notifier) 25 | end 26 | 27 | assert_file "app/views/tweet_notifier/new_mention_in_tweet.json+apn.jbuilder" do |view| 28 | assert_match(/json\.aps do/, view) 29 | end 30 | 31 | assert_file "app/views/tweet_notifier/new_mention_in_tweet.json+fcm.jbuilder" do |view| 32 | assert_match(/json\.to 'REPLACE_WITH_ACTUAL_REGISTRATION_ID_OR_TOPIC'/, view) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/railtie_test.rb: -------------------------------------------------------------------------------- 1 | require "isolated_test_helper" 2 | 3 | class RailtieTest < ActiveSupport::TestCase 4 | include ActiveSupport::Testing::Isolation 5 | include Generation 6 | 7 | setup do 8 | build_app 9 | FileUtils.rm_rf "#{app_path}/config/environments" 10 | end 11 | 12 | teardown do 13 | teardown_app 14 | end 15 | 16 | test "sets pushing load paths" do 17 | add_to_config <<-RUBY 18 | config.root = "#{app_path}" 19 | RUBY 20 | 21 | require "#{app_path}/config/environment" 22 | 23 | expanded_path = File.expand_path("app/views", app_path) 24 | assert_equal expanded_path, Pushing::Base.view_paths[0].to_s 25 | end 26 | 27 | test "sets default url options" do 28 | add_to_initializer <<-RUBY 29 | Pushing.configure do |config| 30 | config.default_url_options[:host] = 'www.example.org' 31 | end 32 | RUBY 33 | 34 | require "#{app_path}/config/environment" 35 | 36 | assert_equal 'www.example.org', Pushing::Base.default_url_options[:host] 37 | end 38 | 39 | test "sets asset host" do 40 | add_to_initializer <<-RUBY 41 | Pushing.configure do |config| 42 | config.asset_host = 'https://www.example.org' 43 | end 44 | RUBY 45 | 46 | require "#{app_path}/config/environment" 47 | 48 | assert_equal 'https://www.example.org', Pushing::Base.asset_host 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/pushing/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | require "active_support/log_subscriber" 2 | require "active_support/core_ext/string/indent" 3 | 4 | module Pushing 5 | # Implements the ActiveSupport::LogSubscriber for logging notifications when 6 | # a push notification is delivered. 7 | class LogSubscriber < ActiveSupport::LogSubscriber 8 | # A notification was delivered. 9 | def deliver(event) 10 | return unless logger.info? 11 | 12 | event.payload[:notification].each do |platform, payload| 13 | info do 14 | recipients = payload.recipients.map {|r| r.truncate(32) }.join(", ") 15 | " #{platform.upcase}: sent push notification to #{recipients} (#{event.duration.round(1)}ms)" 16 | end 17 | 18 | next unless logger.debug? 19 | debug do 20 | "Payload:\n#{JSON.pretty_generate(payload.payload).indent(2)}\n".indent(2) 21 | end 22 | end 23 | end 24 | 25 | # A notification was generated. 26 | def process(event) 27 | return unless logger.debug? 28 | 29 | debug do 30 | notifier = event.payload[:notifier] 31 | action = event.payload[:action] 32 | 33 | "#{notifier}##{action}: processed outbound push notification in #{event.duration.round(1)}ms" 34 | end 35 | end 36 | 37 | # Use the logger configured for Pushing::Base. 38 | def logger 39 | Pushing::Base.logger 40 | end 41 | end 42 | end 43 | 44 | Pushing::LogSubscriber.attach_to :push_notification 45 | -------------------------------------------------------------------------------- /lib/pushing/adapters.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Pushing 4 | module Adapters 5 | extend ActiveSupport::Autoload 6 | 7 | autoload :HoustonAdapter, 'pushing/adapters/apn/houston_adapter' 8 | autoload :ApnoticAdapter, 'pushing/adapters/apn/apnotic_adapter' 9 | autoload :LowdownAdapter, 'pushing/adapters/apn/lowdown_adapter' 10 | autoload :AndpushAdapter, 'pushing/adapters/fcm/andpush_adapter' 11 | autoload :FcmGemAdapter, 'pushing/adapters/fcm/fcm_gem_adapter' 12 | autoload :TestAdapter 13 | 14 | # Hash object that holds referenses to adapter instances. 15 | ADAPTER_INSTANCES = {} 16 | 17 | # Mutex object used to ensure the +instance+ method creates a singleton object. 18 | MUTEX = Mutex.new 19 | 20 | private_constant :ADAPTER_INSTANCES, :MUTEX 21 | 22 | class << self 23 | ## 24 | # Returns the constant for the specified adapter name. 25 | # 26 | # Pushing::Adapters.lookup(:apnotic) 27 | # # => Pushing::Adapters::ApnoticAdapter 28 | def lookup(name) 29 | const_get("#{name.to_s.camelize}Adapter") 30 | end 31 | 32 | ## 33 | # Provides an adapter instance specified in the +configuration+. If the adapter is not found in 34 | # +ADAPTER_INSTANCES+, it'll look up the adapter class and create a new instance using the 35 | # +configuration+. 36 | def instance(configuration) 37 | ADAPTER_INSTANCES[configuration.adapter] || MUTEX.synchronize do 38 | ADAPTER_INSTANCES[configuration.adapter] ||= lookup(configuration.adapter).new(configuration) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/pushing/railtie.rb: -------------------------------------------------------------------------------- 1 | require "active_job/railtie" 2 | require "rails" 3 | require "abstract_controller/railties/routes_helpers" 4 | 5 | module Pushing 6 | class Railtie < Rails::Railtie # :nodoc: 7 | config.eager_load_namespaces << Pushing 8 | 9 | initializer "pushing.logger" do 10 | ActiveSupport.on_load(:pushing) { self.logger ||= Rails.logger } 11 | end 12 | 13 | initializer "pushing.add_view_paths" do |app| 14 | views = app.config.paths["app/views"].existent 15 | if !views.empty? 16 | ActiveSupport.on_load(:pushing) { prepend_view_path(views) } 17 | end 18 | end 19 | 20 | initializer "pushing.set_configs" do |app| 21 | paths = app.config.paths 22 | options = ActiveSupport::OrderedOptions.new 23 | options.default_url_options = {} 24 | 25 | if app.config.force_ssl 26 | options.default_url_options[:protocol] ||= "https" 27 | end 28 | 29 | options.assets_dir ||= paths["public"].first 30 | 31 | # make sure readers methods get compiled 32 | options.asset_host ||= app.config.asset_host 33 | options.relative_url_root ||= app.config.relative_url_root 34 | 35 | ActiveSupport.on_load(:pushing) do 36 | include AbstractController::UrlFor 37 | extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false) 38 | include app.routes.mounted_helpers 39 | 40 | options.each { |k, v| send("#{k}=", v) } 41 | config.merge!(options) 42 | end 43 | end 44 | 45 | initializer "pushing.compile_config_methods" do 46 | ActiveSupport.on_load(:pushing) do 47 | config.compile_methods! if config.respond_to?(:compile_methods!) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require 'active_support/core_ext/kernel/reporting' 4 | require 'pushing' 5 | 6 | # These are the normal settings that will be set up by Railties 7 | # TODO: Have these tests support other combinations of these values 8 | silence_warnings do 9 | Encoding.default_internal = "UTF-8" 10 | Encoding.default_external = "UTF-8" 11 | end 12 | 13 | require 'active_support/testing/autorun' 14 | require 'minitest/pride' 15 | require 'pry' 16 | require 'pry-byebug' if RUBY_ENGINE == 'ruby' 17 | 18 | # Emulate AV railtie 19 | require 'action_view' 20 | 21 | # Show backtraces for deprecated behavior for quicker cleanup. 22 | ActiveSupport::Deprecation.debug = true 23 | 24 | FIXTURE_LOAD_PATH = File.expand_path('fixtures', File.dirname(__FILE__)) 25 | Pushing::Base.view_paths = FIXTURE_LOAD_PATH 26 | 27 | require "rails" 28 | require 'jbuilder' 29 | require 'jbuilder/jbuilder_template' 30 | 31 | ActionView::Template.register_template_handler :jbuilder, JbuilderHandler 32 | 33 | begin 34 | require 'active_support/testing/method_call_assertions' 35 | ActiveSupport::TestCase.include ActiveSupport::Testing::MethodCallAssertions 36 | rescue LoadError 37 | # Rails 4.2 doesn't come with ActiveSupport::Testing::MethodCallAssertions 38 | require 'backport/method_call_assertions' 39 | ActiveSupport::TestCase.include MethodCallAssertions 40 | 41 | # FIXME: we have tests that depend on run order, we should fix that and 42 | # remove this method call. 43 | require 'active_support/test_case' 44 | ActiveSupport::TestCase.test_order = :sorted 45 | end 46 | 47 | Pushing.config.apn.environment = :development 48 | Pushing.config.apn.adapter = :test 49 | Pushing.config.fcm.adapter = :test 50 | -------------------------------------------------------------------------------- /lib/generators/pushing/notifier_generator.rb: -------------------------------------------------------------------------------- 1 | module Pushing 2 | class NotifierGenerator < Rails::Generators::NamedBase 3 | source_root File.expand_path("../templates", __FILE__) 4 | 5 | argument :actions, type: :array, default: [], banner: "method method" 6 | 7 | check_class_collision suffix: "Notifier" 8 | 9 | def create_notifier_file 10 | template "notifier.rb", File.join("app/notifiers", class_path, "#{file_name}_notifier.rb") 11 | 12 | actions.each do |action| 13 | template "template.json+apn.jbuilder", File.join("app/views/", "#{file_name}_notifier", "#{action}.json+apn.jbuilder") 14 | template "template.json+fcm.jbuilder", File.join("app/views/", "#{file_name}_notifier", "#{action}.json+fcm.jbuilder") 15 | end 16 | 17 | in_root do 18 | if behavior == :invoke && !File.exist?(application_notifier_file_name) 19 | template "application_notifier.rb", application_notifier_file_name 20 | end 21 | 22 | if behavior == :invoke && !File.exist?(initializer_file_name) 23 | template "initializer.rb", initializer_file_name 24 | end 25 | end 26 | end 27 | 28 | private 29 | 30 | def file_name # :doc: 31 | @_file_name ||= super.gsub(/_notifier/i, "") 32 | end 33 | 34 | def initializer_file_name 35 | @_initializer_file_name ||= "config/initializers/pushing.rb" 36 | end 37 | 38 | def application_notifier_file_name 39 | @_application_notifier_file_name ||= if mountable_engine? 40 | "app/notifiers/#{namespaced_path}/application_notifier.rb" 41 | else 42 | "app/notifiers/application_notifier.rb" 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/pushing/adapters/fcm/fcm_gem_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require 'json' 4 | require 'fcm' 5 | require 'active_support/core_ext/hash/slice' 6 | 7 | module Pushing 8 | module Adapters 9 | class FcmGemAdapter 10 | SUCCESS_CODES = (200..299).freeze 11 | 12 | attr_reader :server_key 13 | 14 | def initialize(fcm_settings) 15 | @server_key = fcm_settings.server_key 16 | end 17 | 18 | def push!(notification) 19 | json = notification.payload 20 | ids = json.delete(:registration_ids) || Array(json.delete(:to)) 21 | response = FCM.new(server_key).send(ids, json) 22 | 23 | if SUCCESS_CODES.include?(response[:status_code]) 24 | FcmResponse.new(response.slice(:body, :headers, :status_code).merge(raw_response: response)) 25 | else 26 | raise "#{response[:response]} (response body: #{response[:body]})" 27 | end 28 | rescue => cause 29 | resopnse = FcmResponse.new(response.slice(:body, :headers, :status_code).merge(raw_response: response)) if response 30 | error = Pushing::FcmDeliveryError.new("Error while trying to send push notification: #{cause.message}", resopnse, notification) 31 | 32 | raise error, error.message, cause.backtrace 33 | end 34 | 35 | class FcmResponse 36 | attr_reader :body, :headers, :status_code, :raw_response 37 | 38 | alias code status_code 39 | 40 | def initialize(body: , headers: , status_code: , raw_response: ) 41 | @body, @headers, @status_code, @raw_response = body, headers, status_code, raw_response 42 | end 43 | 44 | def json 45 | @json ||= JSON.parse(body, symbolize_names: true) if body.is_a?(String) 46 | end 47 | end 48 | 49 | private_constant :FcmResponse 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/log_subscriber_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "notifiers/base_notifier" 3 | require "active_support/core_ext/string/strip" 4 | require "active_support/log_subscriber/test_helper" 5 | 6 | class LogSubscriberTest < ActiveSupport::TestCase 7 | include ActiveSupport::LogSubscriber::TestHelper 8 | 9 | def setup 10 | super 11 | Pushing::LogSubscriber.attach_to :push_notification 12 | end 13 | 14 | def set_logger(logger) 15 | Pushing::Base.logger = logger 16 | end 17 | 18 | def test_deliver_is_notified 19 | Pushing::Base.logger.level = 0 20 | BaseNotifier.welcome.deliver_now! 21 | wait 22 | 23 | assert_equal(2, @logger.logged(:info).size) 24 | assert_match(/APN: sent push notification to development\/device-token/, @logger.logged(:info).first) 25 | assert_match(/FCM: sent push notification to device-token/, @logger.logged(:info).second) 26 | 27 | assert_equal(3, @logger.logged(:debug).size) 28 | assert_match(/BaseNotifier#welcome: processed outbound push notification in [\d.]+ms/, @logger.logged(:debug).first) 29 | assert_equal(<<-DEBUG_LOG.strip_heredoc.strip, @logger.logged(:debug).second) 30 | Payload: 31 | { 32 | "aps": { 33 | "alert": "New message!", 34 | "badge": 9, 35 | "sound": "bingbong.aiff" 36 | } 37 | } 38 | DEBUG_LOG 39 | ensure 40 | BaseNotifier.deliveries.clear 41 | end 42 | 43 | def test_deliver_is_notified_in_info 44 | Pushing::Base.logger.level = 1 45 | BaseNotifier.welcome.deliver_now! 46 | wait 47 | 48 | assert_equal(2, @logger.logged(:info).size) 49 | assert_match(/APN: sent push notification to development\/device-token/, @logger.logged(:info).first) 50 | assert_match(/FCM: sent push notification to device-token/, @logger.logged(:info).second) 51 | 52 | assert_equal 0, @logger.logged(:debug).size 53 | ensure 54 | BaseNotifier.deliveries.clear 55 | end 56 | 57 | def test_deliver_is_not_notified_in_warn 58 | Pushing::Base.logger.level = 2 59 | BaseNotifier.welcome.deliver_now! 60 | wait 61 | 62 | assert_equal 0, @logger.logged(:info).size 63 | assert_equal 0, @logger.logged(:debug).size 64 | ensure 65 | BaseNotifier.deliveries.clear 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/integration/apn_http2_test_cases.rb: -------------------------------------------------------------------------------- 1 | module ApnHttp2TestCases 2 | def setup 3 | super 4 | Pushing::Adapters.const_get(:ADAPTER_INSTANCES).clear 5 | end 6 | 7 | def test_actually_push_notification 8 | responses = MaintainerNotifier.build_result(adapter, apn: true).deliver_now! 9 | response = responses.first 10 | 11 | assert_equal 200, response.code 12 | assert_nil response.json 13 | assert_match /\A\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\z/, response.headers["apns-id"] 14 | end 15 | 16 | def test_push_notification_with_custom_config 17 | # Set the wrong topic/environment to make sure you can override these on the fly 18 | Pushing.config.apn.environment = :production 19 | Pushing.config.apn.default_headers = { 20 | apns_topic: 'wrong.topicname.com' 21 | } 22 | 23 | apns_id = SecureRandom.uuid 24 | headers = { 25 | apns_id: apns_id, 26 | apns_expiration: 1.hour.from_now, 27 | apns_priority: 5, 28 | apns_topic: ENV.fetch('APN_TEST_TOPIC'), 29 | apns_collapse_id: 'pushing-testing' 30 | } 31 | 32 | responses = MaintainerNotifier.build_result_with_custom_apn_config(adapter, :development, headers).deliver_now! 33 | response = responses.first 34 | 35 | assert_equal 200, response.code 36 | assert_nil response.json 37 | 38 | if adapter == 'lowdown' 39 | assert_match(/\A\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\z/, response.headers["apns-id"]) 40 | else 41 | assert_match apns_id, response.headers["apns-id"] 42 | end 43 | ensure 44 | Pushing.config.apn.environment = :development 45 | Pushing.config.apn.default_headers = { 46 | apns_topic: ENV.fetch('APN_TEST_TOPIC') 47 | } 48 | end 49 | 50 | def test_notifier_raises_exception_on_http_client_error 51 | error = assert_raises Pushing::ApnDeliveryError do 52 | MaintainerNotifier.build_result(adapter, apn: 'bad-token').deliver_now! 53 | end 54 | 55 | assert_equal 400, error.response.code 56 | assert_equal "BadDeviceToken", error.response.json[:reason] 57 | assert_equal 'bad-token', error.notification.device_token 58 | end 59 | 60 | def test_raise_error_on_error_response 61 | assert_nothing_raised do 62 | NotifierWithRescueHandler.apn.deliver_now! 63 | end 64 | 65 | response = NotifierWithRescueHandler.last_response_from_apn 66 | assert_equal 400, response.code 67 | end 68 | 69 | def adapter 70 | raise NotImplementedError 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/generators/pushing/templates/template.json+apn.jbuilder: -------------------------------------------------------------------------------- 1 | # In this file you can customize the APN payload. For more details about key preference, see 2 | # Apple's offitial documentation here: http://bit.ly/apns-doc 3 | 4 | # the JSON dictionary can include custom keys and values with your app-specific content. 5 | # json.custom_data1 'content1...' 6 | # json.custom_data2 'content2...' 7 | 8 | # The aps dictionary contains the keys used by Apple to deliver the notification to the user's device. 9 | json.aps do 10 | 11 | # Include this key when you want the system to display a standard alert or a banner (Dictionary or String). 12 | # json.alert 'REPLACE_WITH_ACTUAL_TITLE' 13 | json.alert do 14 | # A short string describing the purpose of the notification. 15 | json.title 'REPLACE_WITH_ACTUAL_TITLE' 16 | 17 | # The text of the alert message. 18 | json.body 'REPLACE_WITH_ACTUAL_BODY' 19 | 20 | # The key to a title string in the Localizable.strings file for the current localization. 21 | # json.set! 'title-loc-key', 'key in Localizable.strings' 22 | 23 | # Variable string values to appear in place of the format specifiers in 'title-loc-key'. 24 | # json.set! 'title-loc-args', ['arg1', 'arg2'] 25 | 26 | # If a string is specified, the system displays an alert that includes the Close and View buttons. 27 | # json.set! 'action-loc-key', 'key in Localizable.strings' 28 | 29 | # A key to an alert-message string in a Localizable.strings file for the current localization. 30 | # json.set! 'loc-key', 'key in Localizable.strings' 31 | 32 | # Variable string values to appear in place of the format specifiers in loc-key. 33 | # json.set! 'loc-args', ['arg1', 'arg2'] 34 | 35 | # The filename of an image file in the app bundle, with or without the filename extension. 36 | # json.set! 'launch-image', 'your_launch_iamge.png' 37 | end 38 | 39 | # Include this key when you want the system to modify the badge of your app icon. 40 | json.badge 1 41 | 42 | # Include this key when you want the system to play a sound. 43 | json.sound 'bingbong.aiff' 44 | 45 | # Provide this key with a string value that represents the notification's type. 46 | # json.category 'your-category-type', 'CATEGORY-TYPE' 47 | 48 | # Include this key with a value of 1 to configure a silent notification. 49 | # json.set! 'content-available', 1 50 | 51 | # Provide this key with a string value that represents the app-specific identifier for grouping notifications. 52 | # json.set! 'thread-id', 'THREAD-ID' 53 | end 54 | -------------------------------------------------------------------------------- /lib/pushing/adapters/apn/lowdown_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require 'json' 4 | require 'delegate' 5 | 6 | module Pushing 7 | module Adapters 8 | class LowdownAdapter 9 | attr_reader :clients 10 | 11 | def initialize(apn_settings) 12 | # Don't load lowdown earlier as it may load Celluloid (and start it) 13 | # before daemonizing the workers spun up by a gem (e,g, delayed_job). 14 | require 'lowdown' unless defined?(Lodwown) 15 | 16 | cert = File.read(apn_settings.certificate_path) 17 | @clients = { 18 | development: Lowdown::Client.production(false, certificate: cert, keep_alive: true), 19 | production: Lowdown::Client.production(true, certificate: cert, keep_alive: true) 20 | } 21 | end 22 | 23 | def push!(notification) 24 | if notification.headers[:'apns-id'] 25 | warn("The lowdown gem does not allow for overriding `apns_id'.") 26 | end 27 | 28 | if notification.headers[:'apns-collapse-id'] 29 | warn("The lowdown gem does not allow for overriding `apns-collapse-id'.") 30 | end 31 | 32 | lowdown_notification = Lowdown::Notification.new(token: notification.device_token) 33 | lowdown_notification.payload = notification.payload 34 | 35 | lowdown_notification.expiration = notification.headers[:'apns-expiration'].to_i if notification.headers[:'apns-expiration'] 36 | lowdown_notification.priority = notification.headers[:'apns-priority'] 37 | lowdown_notification.topic = notification.headers[:'apns-topic'] 38 | 39 | response = nil 40 | clients[notification.environment].group do |group| 41 | group.send_notification(lowdown_notification) do |_response| 42 | response = _response 43 | end 44 | end 45 | 46 | raise response.raw_body if !response.success? 47 | ApnResponse.new(response) 48 | rescue => cause 49 | response = response ? ApnResponse.new(response) : nil 50 | error = Pushing::ApnDeliveryError.new("Error while trying to send push notification: #{cause.message}", response, notification) 51 | 52 | raise error, error.message, cause.backtrace 53 | end 54 | 55 | class ApnResponse < SimpleDelegator 56 | def code 57 | __getobj__.status 58 | end 59 | 60 | def json 61 | @json ||= JSON.parse(__getobj__.raw_body, symbolize_names: true) if __getobj__.raw_body 62 | end 63 | end 64 | 65 | private_constant :ApnResponse 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/integration/fcm_test_cases.rb: -------------------------------------------------------------------------------- 1 | module FcmTestCases 2 | def test_actually_push_notification 3 | responses = MaintainerNotifier.build_result(adapter, fcm: true).deliver_now! 4 | response = responses.first 5 | 6 | assert_equal 200, response.code 7 | assert_equal 1, response.json[:success] 8 | assert_equal "application/json; charset=UTF-8", response.headers["content-type"] 9 | end 10 | 11 | class FcmTokenHandler 12 | cattr_accessor :canonical_ids 13 | self.canonical_ids = [] 14 | 15 | def delivered_notification(payload, response) 16 | response.json[:results].select {|result| result[:registration_id] }.each do |result| 17 | self.class.canonical_ids << result[:registration_id] 18 | end 19 | end 20 | end 21 | 22 | def test_observer_can_observe_responses_from_fcm 23 | MaintainerNotifier.register_observer FcmTokenHandler.new 24 | 25 | stub_request(:post, "https://fcm.googleapis.com/fcm/send").to_return( 26 | status: 200, 27 | body: { 28 | multicast_id: 216, 29 | success: 3, 30 | failure: 3, 31 | canonical_ids: 1, 32 | results: [ 33 | { message_id: "1:0408" }, 34 | { error: "Unavailable" }, 35 | { error: "InvalidRegistration" }, 36 | { message_id: "1:1516" }, 37 | { message_id: "1:2342", registration_id: "32" }, 38 | { error: "NotRegistered"} 39 | ] 40 | }.to_json 41 | ) 42 | 43 | MaintainerNotifier.build_result(adapter, fcm: true).deliver_now! 44 | 45 | assert_equal ["32"], FcmTokenHandler.canonical_ids 46 | ensure 47 | FcmTokenHandler.canonical_ids.clear 48 | MaintainerNotifier.delivery_notification_observers.clear 49 | end 50 | 51 | def test_notifier_raises_exception_on_http_client_error 52 | stub_request(:post, "https://fcm.googleapis.com/fcm/send").to_return(status: 400) 53 | 54 | error = assert_raises Pushing::FcmDeliveryError do 55 | MaintainerNotifier.build_result(adapter, fcm: true).deliver_now! 56 | end 57 | 58 | assert_equal 400, error.response.code 59 | assert_equal true, error.notification.payload[:dry_run] 60 | end 61 | 62 | def test_notifier_can_rescue_error_on_error_response 63 | stub_request(:post, "https://fcm.googleapis.com/fcm/send").to_return(status: 400) 64 | 65 | assert_nothing_raised do 66 | NotifierWithRescueHandler.fcm.deliver_now! 67 | end 68 | 69 | response = NotifierWithRescueHandler.last_response_from_fcm 70 | assert_equal 400, response.code 71 | end 72 | 73 | def adapter 74 | raise NotImplementedError 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/platforms_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class PlatformsTest < ActiveSupport::TestCase 4 | setup do 5 | @id = 1 6 | @expiration = 1.hour.from_now 7 | @priority = 5 8 | @topic = 'com.yuki.ios' 9 | @collapse_id = 'pushing-testing' 10 | end 11 | 12 | test "APN headers are normalized" do 13 | payload = Pushing::Platforms::ApnPayload.new({}, environment: 'development', headers: { 14 | authorization: "", 15 | 'apns-id': @id, 16 | 'apns-expiration': @expiration, 17 | 'apns-priority': @priority, 18 | 'apns-topic': @topic, 19 | 'apns-collapse-id': @collapse_id 20 | }) 21 | 22 | assert_apn_headers payload.headers 23 | end 24 | 25 | test "APN headers with underscore are normalized" do 26 | payload = Pushing::Platforms::ApnPayload.new({}, environment: 'development', headers: { 27 | authorization: "", 28 | apns_id: @id, 29 | apns_expiration: @expiration, 30 | apns_priority: @priority, 31 | apns_topic: @topic, 32 | apns_collapse_id: @collapse_id 33 | }) 34 | 35 | assert_apn_headers payload.headers 36 | end 37 | 38 | test "APN headers without 'apn-' prefix are normalized" do 39 | payload = Pushing::Platforms::ApnPayload.new({}, environment: 'development', headers: { 40 | authorization: "", 41 | id: @id, 42 | expiration: @expiration, 43 | priority: @priority, 44 | topic: @topic, 45 | collapse_id: @collapse_id 46 | }) 47 | 48 | assert_apn_headers payload.headers 49 | end 50 | 51 | test "default headers do not take the precedence" do 52 | options = { 53 | environment: 'development', 54 | headers: { 55 | priority: 10 56 | } 57 | } 58 | 59 | config = { 60 | default_headers: { 61 | apns_priority: 5 62 | } 63 | } 64 | 65 | payload = Pushing::Platforms::ApnPayload.new({}, options, config) 66 | 67 | assert_equal 10, payload.headers[:'apns-priority'] 68 | assert_not payload.headers.include?(:apns_priority) 69 | assert_not payload.headers.include?(:priority) 70 | end 71 | 72 | private 73 | 74 | def assert_apn_headers(headers) 75 | assert_equal '', headers[:authorization] 76 | assert_equal @id, headers[:'apns-id'] 77 | assert_equal @expiration, headers[:'apns-expiration'] 78 | assert_equal @priority, headers[:'apns-priority'] 79 | assert_equal @topic, headers[:'apns-topic'] 80 | assert_equal @collapse_id, headers[:'apns-collapse-id'] 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/generators/pushing/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | Pushing.configure do |config| 2 | # Adapter that is used to send push notifications through FCM 3 | config.fcm.adapter = Rails.env.test? ? :test : :andpush 4 | 5 | # Your FCM servery key that can be found here: https://console.firebase.google.com/project/_/settings/cloudmessaging 6 | config.fcm.server_key = 'YOUR_FCM_SERVER_KEY' 7 | 8 | # Adapter that is used to send push notifications through APNs 9 | config.apn.adapter = Rails.env.test? ? :test : :apnotic 10 | 11 | # The environment that is used by default to send push notifications through APNs 12 | config.apn.environment = Rails.env.production? ? :production : :development 13 | 14 | # The scheme that is used for negotiating connection trust between your provider 15 | # servers and Apple Push Notification service. As documented in the offitial doc, 16 | # there are two schemes available: 17 | # 18 | # :token - Token-based provider connection trust (default) 19 | # :certificate - Certificate-based provider connection trust 20 | # 21 | # This option is only applied when using an adapter that uses the HTTP/2-based 22 | # API. 23 | config.apn.connection_scheme = :token 24 | 25 | # Path to the certificate or auth key for establishing a connection to APNs. 26 | # 27 | # This config is always required. 28 | config.apn.certificate_path = 'path/to/your/certificate' 29 | 30 | # Password for the certificate specified above if there's any. 31 | # config.apn.certificate_password = 'passphrase' 32 | 33 | # A 10-character key identifier (kid) key, obtained from your developer account. 34 | # If you haven't created an Auth Key for your app, create a new one at: 35 | # https://developer.apple.com/account/ios/authkey/ 36 | # 37 | # Required if the +connection_scheme+ is set to +:token+. 38 | config.apn.key_id = 'DEF123GHIJ' 39 | 40 | # The issuer (iss) registered claim key, whose value is your 10-character Team ID, 41 | # obtained from your developer account. Your team id could be found at: 42 | # https://developer.apple.com/account/#/membership 43 | # 44 | # Required if the +connection_scheme+ is set to +:token+. 45 | config.apn.team_id = 'ABC123DEFG' 46 | 47 | # Header values that are added to every request to APNs. documentation for the 48 | # headers available can be found here: 49 | # https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW13 50 | config.apn.default_headers = { 51 | apns_priority: 10, 52 | apns_topic: 'your.awesomeapp.ios', 53 | apns_collapse_id: 'wrong.topicname.com' 54 | } 55 | end 56 | -------------------------------------------------------------------------------- /lib/pushing/notification_delivery.rb: -------------------------------------------------------------------------------- 1 | require "delegate" 2 | 3 | module Pushing 4 | class NotificationDelivery < Delegator 5 | def initialize(notifier_class, action, *args) #:nodoc: 6 | @notifier_class, @action, @args = notifier_class, action, args 7 | 8 | # The notification is only processed if we try to call any methods on it. 9 | # Typical usage will leave it unloaded and call deliver_later. 10 | @processed_notifier = nil 11 | @notification_message = nil 12 | end 13 | 14 | def __getobj__ #:nodoc: 15 | @notification_message ||= processed_notifier.notification 16 | end 17 | 18 | # Unused except for delegator internals (dup, marshaling). 19 | def __setobj__(notification_message) #:nodoc: 20 | @notification_message = notification_message 21 | end 22 | 23 | def message 24 | __getobj__ 25 | end 26 | 27 | def processed? 28 | @processed_notifier || @notification_message 29 | end 30 | 31 | def deliver_later!(options = {}) 32 | enqueue_delivery :deliver_now!, options 33 | end 34 | 35 | def deliver_now! 36 | processed_notifier.handle_exceptions do 37 | message.process { do_deliver } 38 | end 39 | end 40 | 41 | private 42 | 43 | def do_deliver 44 | @notifier_class.inform_interceptors(self) 45 | 46 | responses = nil 47 | @notifier_class.deliver_notification(self) do 48 | responses = message.to_h.map do |platform, payload| 49 | Adapters.instance(@notifier_class.config[platform]).push!(payload) if @notifier_class.config[platform] 50 | end.compact 51 | end 52 | 53 | responses.each {|response| @notifier_class.inform_observers(self, response) } 54 | responses 55 | end 56 | 57 | def processed_notifier 58 | @processed_notifier ||= begin 59 | notifier = @notifier_class.new 60 | notifier.process @action, *@args 61 | notifier 62 | end 63 | end 64 | 65 | def enqueue_delivery(delivery_method, options = {}) 66 | if processed? 67 | ::Kernel.raise "You've accessed the message before asking to " \ 68 | "deliver it later, so you may have made local changes that would " \ 69 | "be silently lost if we enqueued a job to deliver it. Why? Only " \ 70 | "the notifier method *arguments* are passed with the delivery job! " \ 71 | "Do not access the message in any way if you mean to deliver it " \ 72 | "later. Workarounds: 1. don't touch the message before calling " \ 73 | "#deliver_later, 2. only touch the message *within your notifier " \ 74 | "method*, or 3. use a custom Active Job instead of #deliver_later." 75 | else 76 | args = @notifier_class.name, @action.to_s, delivery_method.to_s, *@args 77 | ::Pushing::DeliveryJob.set(options).perform_later(*args) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/generators/pushing/templates/template.json+fcm.jbuilder: -------------------------------------------------------------------------------- 1 | # In this file you can customize the FCM payload. For more details about Firebase Cloud Messaging HTTP Protocol, see 2 | # Google's offitial documentation here: https://goo.gl/tgssxy 3 | 4 | # The recipient of a message. The value can be a device's registration token, a device group's notification key, or a 5 | # single topic (prefixed with /topics/). Either the 'to' or 'registration_ids' key should be present. 6 | json.to 'REPLACE_WITH_ACTUAL_REGISTRATION_ID_OR_TOPIC' 7 | 8 | # The recipient of a multicast message, a message sent to more than one registration token. Either the 'to' or 9 | # 'registration_ids' key should be present. 10 | # json.registration_ids 'REPLACE_WITH_ACTUAL_REGISTRATION_IDS_OR_TOPIC' 11 | 12 | # A logical expression of conditions that determine the message target. 13 | # json.condition 'Topic' 14 | 15 | # This parameter identifies a group of messages (e.g., with collapse_key: 16 | # "Updates Available") that can be collapsed. 17 | # json.collapse_key "Updates Available" 18 | 19 | # Sets the priority of the message. Valid values are "normal" and "high". 20 | # json.priority "high" 21 | 22 | # How long (in seconds) the message should be kept in FCM storage if the device is offline. 23 | # json.time_to_live 4.weeks.to_i 24 | 25 | # The package name of the application where the registration tokens must match in order to receive the message. 26 | # json.restricted_package_name 'com.yourdomain.app' 27 | 28 | # This parameter, when set to true, allows developers to test a request without actually sending a message. 29 | # json.dry_run true 30 | 31 | # The custom key-value pairs of the message's payload. 32 | # json.data do 33 | # json.custom_data "data..." 34 | # end 35 | 36 | # The predefined, user-visible key-value pairs of the notification payload. 37 | json.notification do 38 | # The notification's title. 39 | json.title 'REPLACE_WITH_ACTUAL_TITLE' 40 | 41 | # The notification's body text. 42 | json.body 'REPLACE_WITH_ACTUAL_BODY' 43 | 44 | # The notification's channel id (new in Android O). 45 | # json.android_channel_id "my_channel_01" 46 | 47 | # The notification's icon. 48 | json.icon 1 49 | 50 | # The sound to play when the device receives the notificatio 51 | json.sound 'default' 52 | 53 | # Identifier used to replace existing notifications in the notification drawer. 54 | # json.tag 'tag-name' 55 | 56 | # The notification's icon color, expressed in #rrggbb format. 57 | # json.color "#ffffff" 58 | 59 | # The action associated with a user click on the notification. 60 | # json.click_action "OPEN_ACTIVITY_1" 61 | 62 | # The key to the body in the app's string resources to use to localize the body to the user's current localization. 63 | # json.body_loc_key 'key in Localizable.strings' 64 | 65 | # String values to be used in place of the format specifiers in 'body_loc_key' to use to localize the body to the 66 | # user's current localization. 67 | # json.body_loc_args ['arg1', 'arg2'] 68 | 69 | # The key to the title in the app's string resources to use to localize the title to the user's current localization. 70 | # json.title_loc_key 'key in Localizable.strings' 71 | 72 | # String values to be used in place of the format specifiers in title_loc_key to use to localize the title text to 73 | # the user's current localization. 74 | # json.title_loc_args ['arg1', 'arg2'] 75 | end 76 | -------------------------------------------------------------------------------- /test/url_helper_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require "action_controller" 5 | 6 | class WelcomeController < ActionController::Base 7 | end 8 | 9 | AppRoutes = ActionDispatch::Routing::RouteSet.new 10 | 11 | Pushing::Base.include AppRoutes.url_helpers 12 | 13 | class UrlTestNotifier < Pushing::Base 14 | self.default_url_options[:host] = "example.org" 15 | self.asset_host = "https://example.org" 16 | 17 | configure do |c| 18 | c.assets_dir = "" # To get the tests to pass 19 | end 20 | 21 | def url(options) 22 | @options = options 23 | @url_for = url_for(options) 24 | @welcome_url = url_for host: "example.org", controller: "welcome", action: "greeting" 25 | 26 | push apn: 'token' 27 | end 28 | end 29 | 30 | class UrlHelperTest < ActiveSupport::TestCase 31 | class DummyModel 32 | def self.model_name 33 | OpenStruct.new(route_key: "dummy_model") 34 | end 35 | 36 | def persisted? 37 | false 38 | end 39 | 40 | def model_name 41 | self.class.model_name 42 | end 43 | 44 | def to_model 45 | self 46 | end 47 | end 48 | 49 | def assert_url_for(expected, options, relative = false) 50 | expected = "http://example.org#{expected}" if expected.start_with?("/") && !relative 51 | urls = UrlTestNotifier.url(options).apn.payload.reject{ |key, _| key == :aps }.values 52 | 53 | assert_equal expected, urls.first 54 | assert_equal expected, urls.second 55 | end 56 | 57 | test '#url_for' do 58 | AppRoutes.draw do 59 | ActiveSupport::Deprecation.silence do 60 | get ":controller(/:action(/:id))" 61 | get "/welcome" => "foo#bar", as: "welcome" 62 | get "/dummy_model" => "foo#baz", as: "dummy_model" 63 | end 64 | end 65 | 66 | # string 67 | assert_url_for "http://foo/", "http://foo/" 68 | 69 | # symbol 70 | assert_url_for "/welcome", :welcome 71 | 72 | # hash 73 | assert_url_for "/a/b/c", controller: "a", action: "b", id: "c" 74 | assert_url_for "/a/b/c", { controller: "a", action: "b", id: "c", only_path: true }, true 75 | 76 | # model 77 | assert_url_for "/dummy_model", DummyModel.new 78 | 79 | # class 80 | assert_url_for "/dummy_model", DummyModel 81 | 82 | # array 83 | assert_url_for "/dummy_model", [DummyModel] 84 | end 85 | 86 | test 'url helpers' do 87 | AppRoutes.draw do 88 | ActiveSupport::Deprecation.silence do 89 | get ":controller(/:action(/:id))" 90 | get "/welcome" => "foo#bar", as: "welcome" 91 | end 92 | end 93 | 94 | payload = UrlTestNotifier.url(:welcome).apn.payload 95 | 96 | assert_equal 'http://example.org/welcome/greeting', payload[:welcome_url_from_action] 97 | assert_equal 'http://example.org/welcome', payload[:welcome_url_in_view] 98 | end 99 | 100 | test 'asset url helpers' do 101 | AppRoutes.draw do 102 | ActiveSupport::Deprecation.silence do 103 | get ":controller(/:action(/:id))" 104 | get "/welcome" => "foo#bar", as: "welcome" 105 | end 106 | end 107 | 108 | payload = UrlTestNotifier.url(:welcome).apn.payload 109 | 110 | assert_equal 'https://example.org/puppy.jpeg', payload[:asset_url] 111 | assert_equal 'https://example.org/images/puppy.jpeg', payload[:image_url] 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at mail@yukinishijima.net. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/pushing/adapters/apn/apnotic_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require 'apnotic' 4 | require 'active_support/core_ext/hash/keys' 5 | 6 | module Pushing 7 | module Adapters 8 | class ApnoticAdapter 9 | APS_DICTIONARY_KEYS = %i[ 10 | alert 11 | badge 12 | sound 13 | content_available 14 | category 15 | url_args 16 | mutable_content 17 | ].freeze 18 | 19 | DEFAULT_ADAPTER_OPTIONS = { 20 | size: Process.getrlimit(Process::RLIMIT_NOFILE).first / 8 21 | }.freeze 22 | 23 | attr_reader :connection_pool 24 | 25 | def initialize(apn_settings) 26 | options = case apn_settings.connection_scheme.to_sym 27 | when :token 28 | { 29 | auth_method: :token, 30 | cert_path: apn_settings.certificate_path, 31 | key_id: apn_settings.key_id, 32 | team_id: apn_settings.team_id 33 | } 34 | when :certificate 35 | { 36 | cert_path: apn_settings.certificate_path, 37 | cert_pass: apn_settings.certificate_password 38 | } 39 | else 40 | raise "Unknown connection scheme #{apn_settings.connection_scheme.inspect}. " \ 41 | "The connection scheme should either be :token or :certificate." 42 | end 43 | 44 | @connection_pool = { 45 | development: Apnotic::ConnectionPool.development(options, DEFAULT_ADAPTER_OPTIONS) { }, 46 | production: Apnotic::ConnectionPool.new(options, DEFAULT_ADAPTER_OPTIONS) { }, 47 | } 48 | end 49 | 50 | def push!(notification) 51 | message = Apnotic::Notification.new(notification.device_token) 52 | json = notification.payload.dup 53 | 54 | if aps = json.delete(:aps) 55 | APS_DICTIONARY_KEYS.each {|key| message.instance_variable_set(:"@#{key}", aps[key]) } 56 | end 57 | 58 | message.custom_payload = json 59 | 60 | message.apns_id = notification.headers[:'apns-id'] || message.apns_id 61 | message.expiration = notification.headers[:'apns-expiration'].to_i 62 | message.priority = notification.headers[:'apns-priority'] 63 | message.topic = notification.headers[:'apns-topic'] 64 | message.apns_collapse_id = notification.headers[:'apns-collapse-id'] 65 | 66 | response = connection_pool[notification.environment].with {|connection| connection.push(message) } 67 | 68 | if !response 69 | raise "Timeout sending a push notification" 70 | elsif response.status != '200' 71 | raise response.body.to_s 72 | end 73 | 74 | ApnResponse.new(response) 75 | rescue => cause 76 | response = response ? ApnResponse.new(response) : nil 77 | error = Pushing::ApnDeliveryError.new("Error while trying to send push notification: #{cause.message}", response, notification) 78 | 79 | raise error, error.message, cause.backtrace 80 | end 81 | 82 | class ApnResponse < SimpleDelegator 83 | def code 84 | __getobj__.status.to_i 85 | end 86 | alias status code 87 | 88 | def json 89 | @json ||= __getobj__.body.symbolize_keys if !__getobj__.body.empty? 90 | end 91 | alias body json 92 | end 93 | 94 | private_constant :ApnResponse 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/pushing/platforms.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require 'active_support/core_ext/hash/keys' 4 | 5 | module Pushing 6 | module Platforms 7 | class << self 8 | def configure(&block) #:nodoc: 9 | ActiveSupport::Deprecation.warn "`Pushing::Platforms.configure' is deprecated and will be removed in 0.4.0. " \ 10 | "Please use `Pushing.configure' instead" 11 | 12 | Pushing.configure(&block) 13 | end 14 | 15 | def config #:nodoc: 16 | ActiveSupport::Deprecation.warn "`Pushing::Platforms.config' is deprecated and will be removed in 0.4.0. " \ 17 | "Please use `Pushing.config' instead" 18 | 19 | Pushing.config 20 | end 21 | 22 | def lookup(platform_name) 23 | const_get(:"#{platform_name.capitalize}Payload") 24 | end 25 | end 26 | 27 | class ApnPayload 28 | attr_reader :payload, :headers, :device_token, :environment 29 | 30 | EMPTY_HASH = {}.freeze 31 | 32 | def self.should_render?(options) 33 | options.is_a?(Hash) ? options[:device_token].present? : options.present? 34 | end 35 | 36 | def initialize(payload, options, config = EMPTY_HASH) 37 | @payload = payload 38 | @environment = config[:environment] 39 | @headers = normalize_headers(config[:default_headers] || EMPTY_HASH) 40 | 41 | if config[:topic] 42 | ActiveSupport::Deprecation.warn "`config.apn.topic' is deprecated and will be removed in 0.3.0. " \ 43 | "Please use `config.apn.default_headers' instead:\n\n" \ 44 | " config.apn.default_headers = {\n" \ 45 | " apns_topic: '#{config[:topic]}'\n" \ 46 | " }", caller 47 | 48 | @headers['apns-topic'] ||= config[:topic] 49 | end 50 | 51 | if options.is_a?(String) 52 | @device_token = options 53 | elsif options.is_a?(Hash) 54 | @device_token = options[:device_token] 55 | @environment = options[:environment] || @environment 56 | @headers = @headers.merge(normalize_headers(options[:headers] || EMPTY_HASH)) 57 | else 58 | raise TypeError, "The :apn key only takes a device token as a string or a hash that has `device_token: \"...\"'." 59 | end 60 | 61 | # raise("APNs environment is required.") if @environment.nil? 62 | # raise("APNs device token is required.") if @device_token.nil? 63 | 64 | @environment = @environment.to_sym 65 | end 66 | 67 | def recipients 68 | Array("#{@environment}/#{@device_token}") 69 | end 70 | 71 | def normalize_headers(headers) 72 | h = headers.stringify_keys 73 | h.transform_keys!(&:dasherize) 74 | 75 | { 76 | authorization: h['authorization'], 77 | 'apns-id': h['apns-id'] || h['id'], 78 | 'apns-expiration': h['apns-expiration'] || h['expiration'], 79 | 'apns-priority': h['apns-priority'] || h['priority'], 80 | 'apns-topic': h['apns-topic'] || h['topic'], 81 | 'apns-collapse-id': h['apns-collapse-id'] || h['collapse-id'], 82 | } 83 | end 84 | end 85 | 86 | class FcmPayload 87 | attr_reader :payload 88 | 89 | def self.should_render?(options) 90 | options.present? 91 | end 92 | 93 | def initialize(payload, *) 94 | @payload = payload 95 | end 96 | 97 | def recipients 98 | Array(payload[:to] || payload[:registration_ids]) 99 | end 100 | end 101 | end 102 | 103 | class DeliveryError < RuntimeError 104 | attr_reader :response, :notification 105 | 106 | def initialize(message, response = nil, notification = nil) 107 | super(message) 108 | @response = response 109 | @notification = notification 110 | end 111 | end 112 | 113 | class ApnDeliveryError < DeliveryError 114 | end 115 | 116 | class FcmDeliveryError < DeliveryError 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/isolated_test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require "active_support" 4 | require 'active_support/core_ext/kernel/reporting' 5 | require 'active_support/deprecation' 6 | require 'active_support/testing/autorun' 7 | 8 | require 'minitest/pride' 9 | require 'pry' 10 | require 'pry-byebug' if RUBY_ENGINE == 'ruby' 11 | require "rails/railtie" 12 | 13 | begin 14 | require 'active_support/testing/method_call_assertions' 15 | ActiveSupport::TestCase.include ActiveSupport::Testing::MethodCallAssertions 16 | rescue LoadError 17 | # Rails 4.2 doesn't come with ActiveSupport::Testing::MethodCallAssertions 18 | require 'backport/method_call_assertions' 19 | ActiveSupport::TestCase.include MethodCallAssertions 20 | 21 | # FIXME: we have tests that depend on run order, we should fix that and 22 | # remove this method call. 23 | require 'active_support/test_case' 24 | ActiveSupport::TestCase.test_order = :sorted 25 | end 26 | 27 | module Paths 28 | def app_template_path 29 | File.join Dir.tmpdir, "app_template" 30 | end 31 | 32 | def tmp_path(*args) 33 | @tmp_path ||= File.realpath(Dir.mktmpdir) 34 | File.join(@tmp_path, *args) 35 | end 36 | 37 | def app_path(*args) 38 | tmp_path(*%w[app] + args) 39 | end 40 | end 41 | 42 | module Generation 43 | extend Paths 44 | include Paths 45 | 46 | # Build an application by invoking the generator and going through the whole stack. 47 | def build_app(options = {}) 48 | @prev_rails_env = ENV["RAILS_ENV"] 49 | ENV["RAILS_ENV"] = "development" 50 | ENV["SECRET_KEY_BASE"] ||= SecureRandom.hex(16) 51 | 52 | FileUtils.rm_rf(app_path) 53 | FileUtils.cp_r(app_template_path, app_path) 54 | 55 | # Delete the initializers unless requested 56 | unless options[:initializers] 57 | Dir["#{app_path}/config/initializers/**/*.rb"].each do |initializer| 58 | File.delete(initializer) 59 | end 60 | end 61 | 62 | routes = File.read("#{app_path}/config/routes.rb") 63 | if routes =~ /(\n\s*end\s*)\z/ 64 | File.open("#{app_path}/config/routes.rb", "w") do |f| 65 | f.puts $` + "\nActiveSupport::Deprecation.silence { match ':controller(/:action(/:id))(.:format)', via: :all }\n" + $1 66 | end 67 | end 68 | 69 | File.open("#{app_path}/config/database.yml", "w") do |f| 70 | f.puts <<-YAML 71 | default: &default 72 | adapter: sqlite3 73 | pool: 5 74 | timeout: 5000 75 | development: 76 | <<: *default 77 | database: db/development.sqlite3 78 | test: 79 | <<: *default 80 | database: db/test.sqlite3 81 | production: 82 | <<: *default 83 | database: db/production.sqlite3 84 | YAML 85 | end 86 | 87 | add_to_config <<-RUBY 88 | config.eager_load = false 89 | config.session_store :cookie_store, key: "_myapp_session" 90 | config.active_support.deprecation = :log 91 | config.active_support.test_order = :random 92 | config.action_controller.allow_forgery_protection = false 93 | config.log_level = :info 94 | RUBY 95 | end 96 | 97 | def teardown_app 98 | ENV["RAILS_ENV"] = @prev_rails_env if @prev_rails_env 99 | FileUtils.rm_rf(tmp_path) 100 | end 101 | 102 | def add_to_config(str) 103 | environment = File.read("#{app_path}/config/application.rb") 104 | if environment =~ /(\n\s*end\s*end\s*)\z/ 105 | File.open("#{app_path}/config/application.rb", "w") do |f| 106 | f.puts $` + "\n#{str}\n" + $1 107 | end 108 | end 109 | end 110 | 111 | def add_to_initializer(str) 112 | File.open("#{app_path}/config/initializers/pushing.rb", "w") do |f| 113 | f.puts "#{str}\n" 114 | end 115 | end 116 | 117 | def self.initialize_app 118 | FileUtils.rm_rf(app_template_path) 119 | FileUtils.mkdir(app_template_path) 120 | 121 | `rails new #{app_template_path} --skip-gemfile --skip-listen --no-rc` 122 | File.open("#{app_template_path}/config/boot.rb", "w") do |f| 123 | f.puts "require 'rails/all'" 124 | end 125 | end 126 | end 127 | 128 | Generation.initialize_app 129 | 130 | require 'pushing' 131 | -------------------------------------------------------------------------------- /test/notification_delivery_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'active_job' 3 | require 'notifiers/base_notifier' 4 | require 'notifiers/delayed_notifier' 5 | 6 | class NotificationDeliveryTest < ActiveSupport::TestCase 7 | include ActiveJob::TestHelper 8 | 9 | setup do 10 | @previous_logger = ActiveJob::Base.logger 11 | @previous_deliver_later_queue_name = Pushing::Base.deliver_later_queue_name 12 | Pushing::Base.deliver_later_queue_name = :test_queue 13 | ActiveJob::Base.logger = Logger.new(nil) 14 | Pushing::Base.deliveries.clear 15 | ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true 16 | ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true 17 | 18 | DelayedNotifier.last_error = nil 19 | DelayedNotifier.last_rescue_from_instance = nil 20 | 21 | @notification = Pushing::NotificationDelivery.new(BaseNotifier, :welcome, args: "value") 22 | end 23 | 24 | teardown do 25 | ActiveJob::Base.logger = @previous_logger 26 | Pushing::Base.deliver_later_queue_name = @previous_deliver_later_queue_name 27 | 28 | DelayedNotifier.last_error = nil 29 | DelayedNotifier.last_rescue_from_instance = nil 30 | end 31 | 32 | def test_should_enqueue_and_run_correctly_in_activejob 33 | @notification.deliver_later! 34 | assert_equal 2, Pushing::Base.deliveries.size 35 | assert_equal 1, Pushing::Base.deliveries.apn.size 36 | assert_equal 1, Pushing::Base.deliveries.fcm.size 37 | ensure 38 | Pushing::Base.deliveries.clear 39 | end 40 | 41 | test "should enqueue the notification with :deliver_now! delivery method" do 42 | args = [ 43 | "BaseNotifier", 44 | "welcome", 45 | "deliver_now!", 46 | { args: "value" } 47 | ] 48 | 49 | assert_performed_with job: Pushing::DeliveryJob, args: args, queue: "test_queue" do 50 | @notification.deliver_later! 51 | end 52 | end 53 | 54 | test "should enqueue a delivery with a delay" do 55 | args = [ 56 | "BaseNotifier", 57 | "welcome", 58 | "deliver_now!", 59 | { args: "value" } 60 | ] 61 | 62 | travel_to Time.new(2004, 11, 24, 01, 04, 44) do 63 | assert_performed_with job: Pushing::DeliveryJob, at: Time.current.to_f + 600, args: args do 64 | @notification.deliver_later!(wait: 600.seconds) 65 | end 66 | end 67 | end 68 | 69 | test "should enqueue a delivery at a specific time" do 70 | args = [ 71 | "BaseNotifier", 72 | "welcome", 73 | "deliver_now!", 74 | { args: "value" } 75 | ] 76 | 77 | later_time = Time.now.to_f + 3600 78 | assert_performed_with job: Pushing::DeliveryJob, at: later_time, args: args do 79 | @notification.deliver_later!(wait_until: later_time) 80 | end 81 | end 82 | 83 | test "can override the queue when enqueuing notification" do 84 | args = [ 85 | "BaseNotifier", 86 | "welcome", 87 | "deliver_now!", 88 | { args: "value" } 89 | ] 90 | 91 | assert_performed_with job: Pushing::DeliveryJob, args: args, queue: "another_queue" do 92 | @notification.deliver_later!(queue: :another_queue) 93 | end 94 | end 95 | 96 | test "deliver_later! after accessing the message is disallowed" do 97 | @notification.message # Load the message, which calls the notifier method. 98 | 99 | assert_raise RuntimeError do 100 | @notification.deliver_later! 101 | end 102 | end 103 | 104 | class DeserializationErrorFixture 105 | include GlobalID::Identification 106 | 107 | def self.find(id) 108 | raise "boom, missing find" 109 | end 110 | 111 | attr_reader :id 112 | def initialize(id = 1) 113 | @id = id 114 | end 115 | 116 | def to_global_id(options = {}) 117 | super app: "foo" 118 | end 119 | end 120 | 121 | if ActiveSupport::VERSION::MAJOR > 4 122 | test "job delegates error handling to notifier" do 123 | # Superclass not rescued by notifier's rescue_from RuntimeError 124 | message = DelayedNotifier.test_raise("StandardError") 125 | assert_raise(StandardError) { message.deliver_later! } 126 | assert_nil DelayedNotifier.last_error 127 | assert_nil DelayedNotifier.last_rescue_from_instance 128 | 129 | # Rescued by notifier's rescue_from RuntimeError 130 | message = DelayedNotifier.test_raise("DelayedNotifierError") 131 | assert_nothing_raised { message.deliver_later! } 132 | assert_equal "boom", DelayedNotifier.last_error.message 133 | assert_kind_of DelayedNotifier, DelayedNotifier.last_rescue_from_instance 134 | end 135 | 136 | test "job delegates deserialization errors to notifier class" do 137 | # Inject an argument that can't be deserialized. 138 | message = DelayedNotifier.test_message(arg: DeserializationErrorFixture.new) 139 | 140 | # DeserializationError is raised, rescued, and delegated to the handler 141 | # on the notifier class. 142 | assert_nothing_raised { message.deliver_later! } 143 | assert_equal DelayedNotifier, DelayedNotifier.last_rescue_from_instance 144 | assert_equal "Error while trying to deserialize arguments: boom, missing find", DelayedNotifier.last_error.message 145 | end 146 | else 147 | test "job does not delegate error handling to notifier" do 148 | message = DelayedNotifier.test_raise("StandardError") 149 | assert_raise(StandardError) { message.deliver_later! } 150 | assert_nil DelayedNotifier.last_error 151 | assert_nil DelayedNotifier.last_rescue_from_instance 152 | 153 | message = DelayedNotifier.test_raise("DelayedNotifierError") 154 | assert_raise(DelayedNotifierError, /boom/) { message.deliver_later! } 155 | end 156 | 157 | test "job does not delegate deserialization errors to notifier class" do 158 | # Inject an argument that can't be deserialized. 159 | message = DelayedNotifier.test_message(arg: DeserializationErrorFixture.new) 160 | 161 | assert_raise(ActiveJob::DeserializationError) { message.deliver_later! } 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/pushing/base.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require "abstract_controller" 4 | require 'active_support/core_ext/module/attribute_accessors' 5 | require 'active_support/core_ext/object/blank' 6 | 7 | require 'pushing/log_subscriber' 8 | require 'pushing/rescuable' 9 | require 'pushing/platforms' 10 | require 'pushing/template_handlers' 11 | 12 | module Pushing 13 | class Base < AbstractController::Base 14 | include Rescuable 15 | 16 | config.apn = ActiveSupport::OrderedOptions.new 17 | config.fcm = ActiveSupport::OrderedOptions.new 18 | 19 | abstract! 20 | 21 | include AbstractController::Rendering 22 | include AbstractController::Logger 23 | include AbstractController::Helpers 24 | include AbstractController::Translation 25 | include AbstractController::AssetPaths 26 | include AbstractController::Callbacks 27 | begin 28 | include AbstractController::Caching 29 | rescue NameError 30 | # AbstractController::Caching does not exist in rails 4.2. No-op. 31 | end 32 | 33 | include ActionView::Rendering 34 | 35 | PROTECTED_IVARS = AbstractController::Rendering::DEFAULT_PROTECTED_INSTANCE_VARIABLES + [:@_action_has_layout] 36 | 37 | def _protected_ivars # :nodoc: 38 | PROTECTED_IVARS 39 | end 40 | 41 | cattr_accessor :deliver_later_queue_name 42 | self.deliver_later_queue_name = :notifiers 43 | 44 | cattr_reader :delivery_notification_observers 45 | @@delivery_notification_observers = [] 46 | 47 | cattr_reader :delivery_interceptors 48 | @@delivery_interceptors = [] 49 | 50 | class << self 51 | delegate :deliveries, :deliveries=, to: Pushing::Adapters::TestAdapter 52 | 53 | # Register one or more Observers which will be notified when notification is delivered. 54 | def register_observers(*observers) 55 | observers.flatten.compact.each { |observer| register_observer(observer) } 56 | end 57 | 58 | # Register one or more Interceptors which will be called before notification is sent. 59 | def register_interceptors(*interceptors) 60 | interceptors.flatten.compact.each { |interceptor| register_interceptor(interceptor) } 61 | end 62 | 63 | # Register an Observer which will be notified when notification is delivered. 64 | # Either a class, string or symbol can be passed in as the Observer. 65 | # If a string or symbol is passed in it will be camelized and constantized. 66 | def register_observer(observer) 67 | unless delivery_notification_observers.include?(observer) 68 | delivery_notification_observers << observer 69 | end 70 | end 71 | 72 | # Register an Interceptor which will be called before notification is sent. 73 | # Either a class, string or symbol can be passed in as the Interceptor. 74 | # If a string or symbol is passed in it will be camelized and constantized. 75 | def register_interceptor(interceptor) 76 | unless delivery_interceptors.include?(interceptor) 77 | delivery_interceptors << interceptor 78 | end 79 | end 80 | 81 | def inform_observers(notification, response) 82 | delivery_notification_observers.each do |observer| 83 | observer.delivered_notification(notification, response) 84 | end 85 | end 86 | 87 | def inform_interceptors(notification) 88 | delivery_interceptors.each do |interceptor| 89 | interceptor.delivering_notification(notification) 90 | end 91 | end 92 | 93 | def notifier_name 94 | @notifier_name ||= anonymous? ? "anonymous" : name.underscore 95 | end 96 | # Allows to set the name of current notifier. 97 | attr_writer :notifier_name 98 | alias :controller_path :notifier_name 99 | 100 | # Wraps a notification delivery inside of ActiveSupport::Notifications instrumentation. 101 | def deliver_notification(notification) #:nodoc: 102 | ActiveSupport::Notifications.instrument("deliver.push_notification") do |payload| 103 | set_payload_for_notification(payload, notification) 104 | yield # Let NotificationDelivery do the delivery actions 105 | end 106 | end 107 | 108 | # Push notifications do not support relative path links. 109 | def supports_path? # :doc: 110 | false 111 | end 112 | 113 | private 114 | 115 | def set_payload_for_notification(payload, notification) 116 | payload[:notifier] = name 117 | payload[:notification] = notification.message.to_h 118 | end 119 | 120 | def method_missing(method_name, *args) 121 | if action_methods.include?(method_name.to_s) 122 | NotificationDelivery.new(self, method_name, *args) 123 | else 124 | super 125 | end 126 | end 127 | 128 | def respond_to_missing?(method, include_all = false) 129 | action_methods.include?(method.to_s) || super 130 | end 131 | end 132 | 133 | def process(method_name, *args) #:nodoc: 134 | payload = { 135 | notifier: self.class.name, 136 | action: method_name, 137 | args: args 138 | } 139 | 140 | ActiveSupport::Notifications.instrument("process.push_notification", payload) do 141 | super 142 | @_notification ||= NullNotification.new 143 | end 144 | end 145 | 146 | class NullNotification #:nodoc: 147 | def respond_to?(string, include_all = false) 148 | true 149 | end 150 | 151 | def method_missing(*args) 152 | nil 153 | end 154 | end 155 | 156 | class Notification < OpenStruct #:nodoc: 157 | def process 158 | yield 159 | end 160 | end 161 | 162 | private_constant :NullNotification, :Notification 163 | 164 | attr_internal :notification 165 | 166 | def push(headers) 167 | return notification if notification && headers.blank? 168 | 169 | payload = {} 170 | headers.select {|platform, _| self.class.config[platform] }.each do |platform, options| 171 | payload_class = ::Pushing::Platforms.lookup(platform) 172 | 173 | if payload_class.should_render?(options) 174 | json = render_json(platform, headers) 175 | payload[platform] = payload_class.new(json, headers[platform], config[platform]) 176 | end 177 | end 178 | 179 | @_notification = Notification.new(payload) 180 | end 181 | 182 | private 183 | 184 | def render_json(platform, headers) 185 | templates_path = headers[:template_path] || self.class.notifier_name 186 | templates_name = headers[:template_name] || action_name 187 | 188 | lookup_context.variants = platform 189 | template = lookup_context.find(templates_name, Array(templates_path)) 190 | 191 | unless template.instance_variable_get(:@compiled) 192 | engine = File.extname(template.identifier).tr!(".", "") 193 | handler = ::Pushing::TemplateHandlers.lookup(engine) 194 | template.instance_variable_set(:@handler, handler) 195 | end 196 | 197 | view_renderer.render_template(view_context, template: template) 198 | end 199 | 200 | ActiveSupport.run_load_hooks(:pushing, self) 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /test/base_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require "set" 3 | require "action_dispatch" 4 | require "active_support/time" 5 | 6 | require 'notifiers/base_notifier' 7 | 8 | class BaseTest < ActiveSupport::TestCase 9 | setup do 10 | BaseNotifier.deliveries.clear 11 | end 12 | 13 | test "method call to notification does not raise error" do 14 | assert_nothing_raised { BaseNotifier.welcome } 15 | end 16 | 17 | # Class level API with method missing 18 | test "should respond to action methods" do 19 | assert_respond_to BaseNotifier, :welcome 20 | assert_not BaseNotifier.respond_to?(:push) 21 | end 22 | 23 | # Basic push notification usage without block 24 | test "push() should set the device tokens and generate json payload" do 25 | notification = BaseNotifier.welcome 26 | 27 | assert_equal 'device-token', notification.apn.device_token 28 | 29 | apn_payload = { 30 | aps: { 31 | alert: "New message!", 32 | badge: 9, 33 | sound: "bingbong.aiff" 34 | } 35 | } 36 | 37 | assert_equal apn_payload, notification.apn.payload 38 | 39 | fcm_payload = { 40 | data: { 41 | message: "Hello FCM!" 42 | }, 43 | to: "device-token" 44 | } 45 | 46 | assert_equal fcm_payload, notification.fcm.payload 47 | end 48 | 49 | test "should be able to render only with a single service" do 50 | BaseNotifier.with_apn_template.deliver_now! 51 | assert_equal 1, BaseNotifier.deliveries.length 52 | assert_equal 1, BaseNotifier.deliveries.apn.length 53 | assert_equal 0, BaseNotifier.deliveries.fcm.length 54 | 55 | BaseNotifier.with_fcm_template.deliver_now! 56 | assert_equal 2, BaseNotifier.deliveries.length 57 | assert_equal 1, BaseNotifier.deliveries.apn.length 58 | assert_equal 1, BaseNotifier.deliveries.fcm.length 59 | end 60 | 61 | test "should not render if apn device token is falsy" do 62 | BaseNotifier.with_no_apn_device_token.deliver_now! 63 | assert_equal 0, BaseNotifier.deliveries.length 64 | assert_equal 0, BaseNotifier.deliveries.apn.length 65 | assert_equal 0, BaseNotifier.deliveries.fcm.length 66 | end 67 | 68 | test "calling deliver on the action should increment the deliveries collection if using the test notifier" do 69 | BaseNotifier.welcome.deliver_now! 70 | assert_equal 2, BaseNotifier.deliveries.length 71 | assert_equal 1, BaseNotifier.deliveries.apn.length 72 | assert_equal 1, BaseNotifier.deliveries.fcm.length 73 | end 74 | 75 | test "should raise if missing template" do 76 | assert_raises ActionView::MissingTemplate do 77 | BaseNotifier.missing_apn_template.deliver_now! 78 | end 79 | assert_raises ActionView::MissingTemplate do 80 | BaseNotifier.missing_fcm_template.deliver_now! 81 | end 82 | 83 | assert_equal 0, BaseNotifier.deliveries.length 84 | end 85 | 86 | test "the view is not rendered when notification was never called" do 87 | notification = BaseNotifier.without_push_call 88 | notification.deliver_now! 89 | 90 | assert_nil notification.apn 91 | assert_nil notification.fcm 92 | end 93 | 94 | test "the return value of notifier methods is not relevant" do 95 | notification = BaseNotifier.with_nil_as_return_value 96 | 97 | apn_payload = { 98 | aps: { 99 | alert: "New message!", 100 | } 101 | } 102 | 103 | assert_equal apn_payload, notification.apn.payload 104 | assert_equal 'device-token', notification.apn.device_token 105 | 106 | notification.deliver_now! 107 | end 108 | 109 | # Before and After hooks 110 | 111 | class MyObserver 112 | def self.delivered_notification(notification, response) 113 | end 114 | end 115 | 116 | class MySecondObserver 117 | def self.delivered_notification(notification, response) 118 | end 119 | end 120 | 121 | test "you can register an observer to the notifier object that gets informed on notification delivery" do 122 | notification_side_effects do 123 | Pushing::Base.register_observer(MyObserver) 124 | notification = BaseNotifier.with_apn_template 125 | assert_called_with(MyObserver, :delivered_notification, [notification, notification.apn]) do 126 | notification.deliver_now! 127 | end 128 | end 129 | end 130 | 131 | def notification_side_effects 132 | old_observers = Pushing::Base.class_variable_get(:@@delivery_notification_observers) 133 | old_delivery_interceptors = Pushing::Base.class_variable_get(:@@delivery_interceptors) 134 | yield 135 | ensure 136 | Pushing::Base.class_variable_set(:@@delivery_notification_observers, old_observers) 137 | Pushing::Base.class_variable_set(:@@delivery_interceptors, old_delivery_interceptors) 138 | end 139 | 140 | test "you can register multiple observers to the notification object that both get informed on notification delivery" do 141 | notification_side_effects do 142 | Pushing::Base.register_observers(BaseTest::MyObserver, MySecondObserver) 143 | notification = BaseNotifier.with_apn_template 144 | assert_called_with(MyObserver, :delivered_notification, [notification, notification.apn]) do 145 | assert_called_with(MySecondObserver, :delivered_notification, [notification, notification.apn]) do 146 | notification.deliver_now! 147 | end 148 | end 149 | end 150 | end 151 | 152 | class MyInterceptor 153 | def self.delivering_notification(notification); end 154 | def self.previewing_notification(notification); end 155 | end 156 | 157 | class MySecondInterceptor 158 | def self.delivering_notification(notification); end 159 | def self.previewing_notification(notification); end 160 | end 161 | 162 | test "you can register an interceptor to the notification object that gets passed the notification object before delivery" do 163 | notification_side_effects do 164 | Pushing::Base.register_interceptor(MyInterceptor) 165 | notification = BaseNotifier.welcome 166 | assert_called_with(MyInterceptor, :delivering_notification, [notification]) do 167 | notification.deliver_now! 168 | end 169 | end 170 | end 171 | 172 | test "you can register multiple interceptors to the notification object that both get passed the notification object before delivery" do 173 | notification_side_effects do 174 | Pushing::Base.register_interceptors(BaseTest::MyInterceptor, MySecondInterceptor) 175 | notification = BaseNotifier.welcome 176 | assert_called_with(MyInterceptor, :delivering_notification, [notification]) do 177 | assert_called_with(MySecondInterceptor, :delivering_notification, [notification]) do 178 | notification.deliver_now! 179 | end 180 | end 181 | end 182 | end 183 | 184 | test "modifying the notification message with a before_action" do 185 | class BeforeActionNotifier < Pushing::Base 186 | before_action :filter 187 | 188 | def welcome ; notification ; end 189 | 190 | cattr_accessor :called 191 | self.called = false 192 | 193 | private 194 | def filter 195 | self.class.called = true 196 | end 197 | end 198 | 199 | BeforeActionNotifier.welcome.message 200 | 201 | assert BeforeActionNotifier.called, "Before action didn't get called." 202 | end 203 | 204 | test "modifying the notification message with an after_action" do 205 | class AfterActionNotifier < Pushing::Base 206 | after_action :filter 207 | 208 | def welcome ; notification ; end 209 | 210 | cattr_accessor :called 211 | self.called = false 212 | 213 | private 214 | def filter 215 | self.class.called = true 216 | end 217 | end 218 | 219 | AfterActionNotifier.welcome.message 220 | 221 | assert AfterActionNotifier.called, "After action didn't get called." 222 | end 223 | 224 | test "action methods should be refreshed after defining new method" do 225 | class FooNotifier < Pushing::Base 226 | # This triggers action_methods. 227 | respond_to?(:foo) 228 | 229 | def notify 230 | end 231 | end 232 | 233 | assert_equal Set.new(["notify"]), FooNotifier.action_methods 234 | end 235 | 236 | test "notification for process" do 237 | begin 238 | events = [] 239 | ActiveSupport::Notifications.subscribe("process.push_notification") do |*args| 240 | events << ActiveSupport::Notifications::Event.new(*args) 241 | end 242 | 243 | BaseNotifier.welcome.deliver_now! 244 | 245 | assert_equal 1, events.length 246 | assert_equal "process.push_notification", events[0].name 247 | assert_equal "BaseNotifier", events[0].payload[:notifier] 248 | assert_equal :welcome, events[0].payload[:action] 249 | assert_equal [], events[0].payload[:args] 250 | ensure 251 | ActiveSupport::Notifications.unsubscribe "process.push_notification" 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pushing: ActionMailer for Push Notifications [![Build Status](https://travis-ci.org/yuki24/pushing.svg?branch=master)](https://travis-ci.org/yuki24/pushing) 2 | 3 | Pushing is a push notification framework that implements interfaces similar to ActionMailer. 4 | 5 | * **Convention over Configuration**: Pushing brings Convention over Configuration to your app for organizing your push notification implementations. 6 | * **Extremely Easy to Learn**: If you know how to use ActionMailer, you already know how to use Pushing. Send notifications asynchronously with ActiveJob at no learning cost. 7 | * **Testability**: First-class support for push notification. No more hassle writing custom code or stubs/mocks for your tests. 8 | 9 | ## Getting Started 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'pushing' 15 | gem 'jbuilder' # if you don't have it in your Gemfile 16 | ``` 17 | 18 | At the time of writing, Pushing only has support for [jbuilder](https://github.com/rails/jbuilder) (Rails' default JSON constructor), but there are plans to add support for [jb](https://github.com/amatsuda/jb) and [rabl](https://github.com/nesquena/rabl). 19 | 20 | ### Supported Platforms 21 | 22 | Pushing itself doesn't make HTTP requests. Instead, it uses an adapter to make actual calls. Currently, Pushing has support for the following client gems: 23 | 24 | * [APNs](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/APNSOverview.html#//apple_ref/doc/uid/TP40008194-CH8-SW1) (iOS): 25 | * [anpotic](https://github.com/ostinelli/apnotic) (recommended) 26 | * [lowdown](https://github.com/alloy/lowdown) 27 | * [houston](https://github.com/nomad/houston) 28 | 29 | * [FCM](https://firebase.google.com/docs/cloud-messaging/) (Android): 30 | * [andpush](https://github.com/yuki24/andpush) (recommended) 31 | * [fcm](https://github.com/spacialdb/fcm) 32 | 33 | If you are starting from scratch, it is recommended using [anpotic](https://github.com/ostinelli/apnotic) for APNs and [andpush](https://github.com/yuki24/andpush) for FCM due to their reliability and performance: 34 | 35 | ```ruby 36 | gem 'apnotic' # APNs 37 | gem 'andpush' # FCM 38 | ``` 39 | 40 | ### Walkthrough to Writing a Notifier 41 | 42 | #### Generate a new notifier: 43 | 44 | ```sh 45 | $ rails g pushing:notifier TweetNotifier new_direct_message 46 | ``` 47 | 48 | ```ruby 49 | # app/notifiers/tweet_notifier.rb 50 | class TweetNotifier < ApplicationNotifier 51 | def new_direct_message(message_id, token_id) 52 | @message = DirectMessage.find(message_id) 53 | @token = DeviceToken.find(token_id) 54 | 55 | push apn: @token.apn? && @token.device_token, fcm: @token.fcm? 56 | end 57 | end 58 | ``` 59 | 60 | #### Edit the push notification payload: 61 | 62 | APNs: 63 | 64 | ```ruby 65 | # app/views/tweet_notifier/new_direct_message.json+apn.jbuilder 66 | json.aps do 67 | json.alert do 68 | json.title "#{@tweet.user.display_name} tweeted:" 69 | json.body truncate(@tweet.body, length: 235) 70 | end 71 | 72 | json.badge 1 73 | json.sound 'bingbong.aiff' 74 | end 75 | ``` 76 | 77 | FCM: 78 | 79 | ```ruby 80 | # app/views/tweet_notifier/new_direct_message.json+fcm.jbuilder 81 | json.to @token.registration_id 82 | 83 | json.notification do 84 | json.title "#{@tweet.user.display_name} tweeted:" 85 | json.body truncate(@tweet.body, length: 1024) 86 | 87 | json.icon 1 88 | json.sound 'default' 89 | end 90 | ``` 91 | 92 | ### Deliver the push notifications: 93 | 94 | ```ruby 95 | TweetNotifier.new_direct_message(message_id, device_token.id).deliver_now! 96 | # => sends a push notification immediately 97 | 98 | TweetNotifier.new_direct_message(message_id, device_token.id).deliver_later! 99 | # => enqueues a job that sends a push notification later 100 | ``` 101 | 102 | ## Advanced Usage 103 | 104 | ### Pushing only to one platform 105 | 106 | Pushing only sends a notification for the platforms that are given a truthy value. For example, give the following code: 107 | 108 | ```ruby 109 | push apn: @token.device_token, fcm: false 110 | # => only sends a push notification to APNs 111 | 112 | push apn: @token.device_token 113 | # => same as above but without the `:fcm` key, only sends a push notification to APNs 114 | ``` 115 | 116 | This will only send a push notification to APNs and skip the call to FCM. 117 | 118 | ### APNs 119 | 120 | It is often necessary to switch the environment endpoint or adjust the request headers depending on the notification you want to send. Pushing's `#push` method allows for overriding APNs request headers on a delivery-basis: 121 | 122 | #### Overriding the default environment: 123 | 124 | ```ruby 125 | push apn: { device_token: @token.device_token, environment: @token.apn_environment } 126 | ``` 127 | 128 | #### Overriding the default APN topic: 129 | 130 | ```ruby 131 | push apn: { device_token: @token.device_token, headers: { apns_topic: 'your.otherapp.ios' } } 132 | ``` 133 | 134 | #### Or all of the above: 135 | 136 | ```ruby 137 | push fcm: @token.fcm?, 138 | apn: { 139 | device_token: @token.apn? && @token.device_token, 140 | environment: @token.apn_environment, 141 | headers: { 142 | apns_id: uuid, 143 | apns_expiration: 7.days.from_now, 144 | apns_priority: 5, 145 | apns_topic: 'your.otherapp.ios', 146 | apns_collapse_id: 'not-so-important-notification' 147 | } 148 | } 149 | ``` 150 | 151 | The `:fcm` key, on the other hand, doesn't have any options as everything's configurable through the request body. 152 | 153 | ## Error Handling 154 | 155 | Like ActionMailer, you can use the `rescue_from` hook to handle exceptions. A common use-case would be to handle a **'BadDeviceToken'** response from APNs or a response with a **'Retry-After'** header from FCM. 156 | 157 | **Handling a 'BadDeviceToken' response from APNs**: 158 | 159 | ```ruby 160 | class ApplicationNotifier < Pushing::Base 161 | rescue_from Pushing::ApnDeliveryError do |error| 162 | response = error.response 163 | 164 | if response.status == 410 || (response.status == 400 && response.json[:reason] == 'BadDeviceToken') 165 | token = error.notification.device_token 166 | Rails.logger.info("APN device token #{token} has been expired and will be removed.") 167 | 168 | # delete or expire device token accordingly 169 | else 170 | raise # Make sure to raise any other types of error to re-enqueue the job 171 | end 172 | end 173 | end 174 | ``` 175 | 176 | **Handling a 'Retry-After' header from FCM**: 177 | 178 | ```ruby 179 | class ApplicationNotifier < Pushing::Base 180 | rescue_from Pushing::FcmDeliveryError do |error| 181 | if error.response&.headers['Retry-After'] 182 | # re-enqueue the job honoring the 'Retry-After' header 183 | else 184 | raise # Make sure to raise any other types of error to re-enqueue the job 185 | end 186 | end 187 | end 188 | ``` 189 | 190 | ## Interceptors and Observers 191 | 192 | Pushing implements the Interceptor and Observer patterns. A common use-case would be to update registration ids with canonical ids from FCM: 193 | 194 | ```ruby 195 | # app/observers/fcm_token_handler.rb 196 | class FcmTokenHandler 197 | def delivered_notification(payload, response) 198 | return if response.json[:canonical_ids].to_i.zero? 199 | 200 | response.json[:results].select {|result| result[:registration_id] }.each do |result| 201 | result[:registration_id] # => returns a canonical id 202 | 203 | # Update registration ids accordingly 204 | end 205 | end 206 | end 207 | 208 | # app/notifiers/application_notifier.rb 209 | class ApplicationNotifier < Pushing::Base 210 | register_observer FcmTokenHandler.new 211 | 212 | ... 213 | end 214 | ``` 215 | 216 | ## Configuration 217 | 218 | ##### TODO: Make this section more helpful 219 | 220 | ```ruby 221 | Pushing.configure do |config| 222 | # Adapter that is used to send push notifications through FCM 223 | config.fcm.adapter = Rails.env.test? ? :test : :andpush 224 | 225 | # Your FCM servery key that can be found here: https://console.firebase.google.com/project/_/settings/cloudmessaging 226 | config.fcm.server_key = 'YOUR_FCM_SERVER_KEY' 227 | 228 | # Adapter that is used to send push notifications through APNs 229 | config.apn.adapter = Rails.env.test? ? :test : :apnotic 230 | 231 | # The environment that is used by default to send push notifications through APNs 232 | config.apn.environment = Rails.env.production? ? :production : :development 233 | 234 | # The scheme that is used for negotiating connection trust between your provider 235 | # servers and Apple Push Notification service. As documented in the offitial doc, 236 | # there are two schemes available: 237 | # 238 | # :token - Token-based provider connection trust (default) 239 | # :certificate - Certificate-based provider connection trust 240 | # 241 | # This option is only applied when using an adapter that uses the HTTP/2-based 242 | # API. 243 | config.apn.connection_scheme = :token 244 | 245 | # Path to the certificate or auth key for establishing a connection to APNs. 246 | # 247 | # This config is always required. 248 | config.apn.certificate_path = 'path/to/your/certificate' 249 | 250 | # Password for the certificate specified above if there's any. 251 | # config.apn.certificate_password = 'passphrase' 252 | 253 | # A 10-character key identifier (kid) key, obtained from your developer account. 254 | # If you haven't created an Auth Key for your app, create a new one at: 255 | # https://developer.apple.com/account/ios/authkey/ 256 | # 257 | # Required if the +connection_scheme+ is set to +:token+. 258 | config.apn.key_id = 'DEF123GHIJ' 259 | 260 | # The issuer (iss) registered claim key, whose value is your 10-character Team ID, 261 | # obtained from your developer account. Your team id could be found at: 262 | # https://developer.apple.com/account/#/membership 263 | # 264 | # Required if the +connection_scheme+ is set to +:token+. 265 | config.apn.team_id = 'ABC123DEFG' 266 | 267 | # Header values that are added to every request to APNs. documentation for the 268 | # headers available can be found here: 269 | # https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW13 270 | config.apn.default_headers = { 271 | apns_priority: 10, 272 | apns_topic: 'your.awesomeapp.ios', 273 | apns_collapse_id: 'wrong.topicname.com' 274 | } 275 | end 276 | 277 | ``` 278 | 279 | ## Testing 280 | 281 | Pushing provides first-class support for testing. In order to test your notifier, use the `:test` adapter in the test environment instead of an actual adapter in development/production. 282 | 283 | ```ruby 284 | # config/initializers/pushing.rb 285 | Pushing.configure do |config| 286 | config.apn.adapter = Rails.env.test? ? :test : :apnotic 287 | config.fcm.adapter = Rails.env.test? ? :test : :andpush 288 | end 289 | ``` 290 | 291 | Now you can call the `#deliveries` method on the notifier. Here is an example with [ActiveSupport::TestCase](http://api.rubyonrails.org/classes/ActiveSupport/TestCase.html): 292 | 293 | ```ruby 294 | TweetNotifier.deliveries.clear # => clears the test inbox 295 | 296 | assert_changes -> { TweetNotifier.deliveries.apn.size }, from: 0, to: 1 do 297 | TweetNotifier.new_direct_message(message.id, apn_device_token.id).deliver_now! 298 | end 299 | 300 | apn_message = TweetNotifier.deliveries.apn.first 301 | assert_equal 'apn-device-token', apn_message.device_token 302 | assert_equal "Hey coffee break?", apn_message.payload[:aps][:alert][:body] 303 | 304 | assert_changes -> { TweetNotifier.deliveries.fcm.size }, from: 0, to: 1 do 305 | TweetNotifier.new_direct_message(message.id, fcm_registration_id.id).deliver_now! 306 | end 307 | 308 | fcm_payload = TweetNotifier.deliveries.fcm.first.payload 309 | assert_equal 'fcm-registration-id', fcm_payload[:to] 310 | assert_equal "Hey coffee break?", fcm_payload[:notification][:body] 311 | ``` 312 | 313 | ## Contributing 314 | 315 | Bug reports and pull requests are welcome on GitHub at https://github.com/yuki24/pushing. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 316 | 317 | ## License 318 | 319 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 320 | --------------------------------------------------------------------------------