Skip to content

Commit

Permalink
Better notification sending. Added Feedback Service support.
Browse files Browse the repository at this point in the history
  • Loading branch information
kdonovan committed May 2, 2010
1 parent bd31840 commit a760a88
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 101 deletions.
41 changes: 39 additions & 2 deletions README.rdoc
Expand Up @@ -9,7 +9,7 @@ The apn_sender gem includes a background daemon which processes background messa

== Usage

=== Queueing Messages From Your Application
=== 1. Queueing Messages From Your Application

To queue a message for sending through Apple's Push Notification service from your Rails application:

Expand All @@ -22,7 +22,7 @@ where +token+ is the unique identifier of the iPhone to receive the notification
# :sound #=> The sound file to play on receipt, or true to play the default sound installed with your app
# :custom #=> Hash of application-specific custom data to send along with the notification

=== Getting Messages Actually Sent to Apple
=== 2. Sending Queued Messages

Put your <code>apn_development.pem</code> and <code>apn_production.pem</code> certificates from Apple in your <code>RAILS_ROOT/config/certs</code> directory.

Expand All @@ -40,6 +40,43 @@ For production, you're probably better off running a dedicated daemon and settin

Note the --environment must be explicitly set (separately from your <code>RAILS_ENV</code>) to production in order to send messages via the production APN servers. Any other environment sends messages through Apple's sandbox servers at <code>gateway.sandbox.push.apple.com</code>.

=== 3. Checking Apple's Feedback Service

Since push notifications are a fire-and-forget sorta deal, where you get no indication if your message was received (or if the specified recipient even exists), Apple needed to come up with some other way to ensure their network isn't clogged with thousands of bogus messages (e.g. from developers sending messages to phones where their application <em>used</em> to be installed, but where the user has since removed it). Hence, the Feedback Service.

It's actually really simple - you connect to them periodically and they give you a big dump of tokens you shouldn't send to anymore. The gem wraps this up nicely -- just call:

# APN::Feedback accepts the same optional :environment and :cert_path options as APN::Sender
feedback = APN::Feedback.new()

tokens = feedback.tokens # => Array of device tokens
tokens.each do |token|
# ... custom logic here to stop you app from
# sending further notifications to this token
end

If you're interested in knowing exactly <em>when</em> Apple determined each token was expired (which can be useful in determining if the application re-registered with your service since it first appeared in the expired queue):

items = feedback.data # => Array of APN::FeedbackItem elements
items.each do |item|
item.token
item.timestamp
# ... custom logic here
end

The Feedback Service works as a big queue, and when you connect it pops off all its data and sends it over the wire. This means that connecting a second time will return an empty array. For ease of use, a call to either +tokens+ or +data+ will connect once and cache the data, so if you call either one again it'll continue to use its cached version rather than connecting to Apple a second time to retrieve an empty array.

Forcing a reconnect is as easy as calling either method with the single parameter +true+, but be sure you've already used the existing data because you'll never get it back.


==== Warning: No really, check Apple's Feedback Service occasionally

If you're sending notifications, you should definitely call one of the <code>receive</code> methods periodically, as Apple's policies require it and they apparently monitors providers for compliance. I'd definitely recommend throwing together a quick rake task to take care of this for you (the {whenever library}[http://github.com/javan/whenever] provides a nice wrapper around scheduling tasks to run at certain times (for systems with cron enabled)).





=== Keeping Your Workers Working

There's also an included sample <code>apn_sender.monitrc</code> file in the <code>contrib/</code> folder to help monit handle server restarts and unexpected disasters.
Expand Down
4 changes: 3 additions & 1 deletion lib/apn.rb
Expand Up @@ -22,4 +22,6 @@ def self.notify(token, opts = {})

require 'apn/notification'
require 'apn/notification_job'
require 'apn/sender'
require 'apn/connection/base'
require 'apn/sender'
require 'apn/feedback'
97 changes: 97 additions & 0 deletions lib/apn/connection/base.rb
@@ -0,0 +1,97 @@
require 'socket'
require 'openssl'
require 'resque'

module APN
module Connection
module Base
attr_accessor :opts, :logger

def initialize(opts = {})
@opts = opts

setup_logger
apn_log(:info, "APN::Sender initializing. Establishing connections first...") if @opts[:verbose]
setup_paths

super( APN::QUEUE_NAME ) if self.class.ancestors.include?(Resque::Worker)
end

# Lazy-connect the socket once we try to access it in some way
def socket
setup_connection unless @socket
return @socket
end

protected

def setup_logger
@logger = if defined?(Merb::Logger)
Merb.logger
elsif defined?(RAILS_DEFAULT_LOGGER)
RAILS_DEFAULT_LOGGER
end
end

def apn_log(level, message)
return false unless self.logger
self.logger.send(level, "#{Time.now}: #{message}")
end

def apn_production?
@opts[:environment] && @opts[:environment] != '' && :production == @opts[:environment].to_sym
end

# Get a fix on the .pem certificate we'll be using for SSL
def setup_paths
# Set option defaults
@opts[:cert_path] ||= File.join(File.expand_path(RAILS_ROOT), "config", "certs") if defined?(RAILS_ROOT)
@opts[:environment] ||= RAILS_ENV if defined?(RAILS_ENV)

raise "Missing certificate path. Please specify :cert_path when initializing class." unless @opts[:cert_path]
cert_name = apn_production? ? "apn_production.pem" : "apn_development.pem"
cert_path = File.join(@opts[:cert_path], cert_name)

@apn_cert = File.exists?(cert_path) ? File.read(cert_path) : nil
raise "Missing apple push notification certificate in #{cert_path}" unless @apn_cert
end

# Open socket to Apple's servers
def setup_connection
raise "Missing apple push notification certificate" unless @apn_cert
return true if @socket && @socket_tcp
raise "Trying to open half-open connection" if @socket || @socket_tcp

ctx = OpenSSL::SSL::SSLContext.new
ctx.cert = OpenSSL::X509::Certificate.new(@apn_cert)
ctx.key = OpenSSL::PKey::RSA.new(@apn_cert)

@socket_tcp = TCPSocket.new(apn_host, apn_port)
@socket = OpenSSL::SSL::SSLSocket.new(@socket_tcp, ctx)
@socket.sync = true
@socket.connect
rescue SocketError => error
apn_log(:error, "Error with connection to #{apn_host}: #{error}")
raise "Error with connection to #{apn_host}: #{error}"
end

# Close open sockets
def teardown_connection
apn_log(:info, "Closing connections...") if @opts[:verbose]

begin
@socket.close if @socket
rescue Exception => e
apn_log(:error, "Error closing SSL Socket: #{e}")
end

begin
@socket_tcp.close if @socket_tcp
rescue Exception => e
apn_log(:error, "Error closing TCP Socket: #{e}")
end
end

end
end
end
92 changes: 92 additions & 0 deletions lib/apn/feedback.rb
@@ -0,0 +1,92 @@
require File.expand_path(File.dirname(__FILE__) + '/connection/base')

module APN
# Encapsulates data returned from the {APN Feedback Service}[http://developer.apple.com/iphone/library/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW3].
# Possesses +timestamp+ and +token+ attributes.
class FeedbackItem
attr_accessor :timestamp, :token

def initialize(time, token)
@timestamp = time
@token = token
end

# For convenience, return the token on to_s
def to_s
token
end
end

# When supplied with the certificate path and the desired environment, connects to the {APN Feedback Service}[http://developer.apple.com/iphone/library/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW3]
# and returns any response as an array of APN::FeedbackItem elements.
#
# See README for usage and details.
class Feedback
include APN::Connection::Base

# Returns array of APN::FeedbackItem elements read from Apple. Connects to Apple once and caches the
# data, continues to returns cached data unless called with <code>data(true)</code>, which clears the
# existing feedback array. Note that once you force resetting the cache you loose all previous feedback,
# so be sure you've already processed it.
def data(force = nil)
@feedback = nil if force
@feedback ||= receive
end

# Wrapper around +data+ returning just an array of token strings.
def tokens(force = nil)
data(force).map(&:token)
end

# Prettify to return meaningful status information when printed. Can't add these directly to connection/base, because Resque depends on decoding to_s
def inspect
"#<#{self.class.name}: #{to_s}>"
end

# Prettify to return meaningful status information when printed. Can't add these directly to connection/base, because Resque depends on decoding to_s
def to_s
"#{@socket ? 'Connected' : 'Connection not currently established'} to #{apn_host} on #{apn_port}"
end

protected

# Connects to Apple's Feedback Service and checks if there's anything there for us.
# Returns an array of APN::FeedbackItem pairs
def receive
feedback = []

# Hi Apple
setup_connection

# Unpacking code borrowed from http://github.com/jpoz/APNS/blob/master/lib/apns/core.rb
while line = socket.gets # Read lines from the socket
line.strip!
f = line.unpack('N1n1H140')
feedback << APN::FeedbackItem.new(Time.at(f[0]), f[2])
end

# Bye Apple
teardown_connection

return feedback
end


def apn_host
@apn_host ||= apn_production? ? "feedback.push.apple.com" : "feedback.sandbox.push.apple.com"
end

def apn_port
2196
end

end
end



__END__
# Testing from irb
irb -r 'lib/apn/feedback'

a=APN::Feedback.new(:cert_path => '/Users/kali/Code/insurrection/certs/', :environment => :production)
30 changes: 21 additions & 9 deletions lib/apn/notification.rb
Expand Up @@ -18,28 +18,40 @@ module APN
# APN::Notification.new(token, {:alert => 'Some Alert'})
#
class Notification
attr_accessor :message, :options, :json
attr_accessor :options, :token
def initialize(token, opts)
@options = hash_as_symbols(opts.is_a?(Hash) ? opts : {:alert => opts})
@json = generate_apple_json
hex_token = [token.delete(' ')].pack('H*')
@message = "\0\0 #{hex_token}\0#{json.length.chr}#{json}"
raise "The maximum size allowed for a notification payload is 256 bytes." if @message.size.to_i > 256
@token = token

raise "The maximum size allowed for a notification payload is 256 bytes." if packaged_notification.size.to_i > 256
end

def to_s
@message
packaged_notification
end

# Ensures at least one of <code>%w(alert badge sound)</code> is present
def valid?
return true if %w(alert badge sound).any?{|key| options.keys.include?(key.to_sym) }
false
end

protected


# Completed encoded notification, ready to send down the wire to Apple
def packaged_notification
pt = packaged_token
pm = packaged_message
[0, 0, 32, pt, 0, pm.size, pm].pack("ccca*cca*")
end

# Device token, compressed and hex-ified
def packaged_token
[@token.gsub(/[\s|<|>]/,'')].pack('H*')
end

# Convert the supplied options into the JSON needed for Apple's push notification servers
def generate_apple_json
def packaged_message
hsh = {'aps' => {}}
hsh['aps']['alert'] = @options[:alert].to_s if @options[:alert]
hsh['aps']['badge'] = @options[:badge].to_i if @options[:badge]
Expand All @@ -48,7 +60,7 @@ def generate_apple_json
hsh.merge!(@options[:custom]) if @options[:custom]
hsh.to_json
end

# Symbolize keys, using ActiveSupport if available
def hash_as_symbols(hash)
if hash.respond_to?(:symbolize_keys)
Expand Down
9 changes: 5 additions & 4 deletions lib/apn/notification_job.rb
Expand Up @@ -5,18 +5,19 @@ module APN
class NotificationJob
# Behind the scenes, this is the name of our Resque queue
@queue = APN::QUEUE_NAME

# Build a notification from arguments and send to Apple
def self.perform(token, opts)
msg = APN::Notification.new(token, opts)
raise "Invalid notification options: #{opts.inspect}" unless msg.valid?

worker.send_to_apple( msg )
end


# Only execute this job in specialized APN::Sender workers, since
# standard Resque workers don't maintain the persistent TCP connection.
extend Resque::Plugins::AccessWorkerFromJob
self.required_worker_class = 'APN::Sender'
end
end
end

0 comments on commit a760a88

Please sign in to comment.