From e9404728661a6f6d3bd9dbf75f2dd295326019c2 Mon Sep 17 00:00:00 2001 From: Thierry Passeron Date: Fri, 4 Mar 2011 11:11:35 +0100 Subject: [PATCH] Now handles multiple pem streams to be able to push notifications to multiple applications from only one worker process --- README.textile | 184 ++++++++++++++++++++++---------------------- lib/apns.rb | 2 +- lib/apns/core.rb | 79 ++++++++++++------- lib/apns/payload.rb | 28 ++++++- 4 files changed, 172 insertions(+), 121 deletions(-) diff --git a/README.textile b/README.textile index efe5731..f681d5e 100644 --- a/README.textile +++ b/README.textile @@ -1,11 +1,11 @@ h1. APNS -a gem for the Apple Push Notification Service. +A plugin/Gem for the Apple Push Notification Service. -The connection to Apple is done as needed and last until it is either closed by the system or is timed out. +The connections to Apple are done on demand (per process) and last until they are either closed by the system or are timed out. This is the prefered way for communicating with Apple's push servers. -This is tested to work in Rails 3. +Works in Ruby on Rails 3. h2. Install @@ -19,9 +19,9 @@ rails plugin install git://... h2. Setup: -Convert your certificate +Convert your certificates -In Keychain access export your certificate as a p12. Then run the following command to convert it to a .pem +In Keychain access export your push certificate(s) as a .p12. Then run the following command to convert each .p12 to a .pem
   
@@ -29,97 +29,49 @@ openssl pkcs12 -in cert.p12 -out cert.pem -nodes -clcerts
   
 
-After you have your .pem file. Set what host, port, certificate file location on the APNS class: +After you have your .pem files, copy them to a place where APNS can access them. +You will need to register them with APNS before being able to send Notifications -
-  
-APNS.host = 'gateway.push.apple.com' 
-# gateway.sandbox.push.apple.com is default
-
-APNS.pem  = '/path/to/pem/file'
-# this is the file you just created
-
-APNS.port = 2195 
-# this is also the default. Shouldn't ever have to set this, but just in case Apple goes crazy, you can.
-  
-
- -In a Rails project, you can add an initializer to configure the gem, with for example: +In a Rails project, you can add an initializer to configure the pem(s), with for example:
   
 In file ./config/initializers/APNS.rb:
 
 # Initialize the APNS environment
-
-APNS.pem = case Rails.env
-  when 'development'
-    Rails.root.join("config", "development.pem")
-  when 'production'
-    Rails.root.join("config", "production.pem")
-end
+APNS.pem = Rails.root.join("config", Rails.env + ".pem") # => ./config/{development,production}.pem
 
   
 
-h2. Example (Single notification): +h2. Creating a Payload: -Sending a push notification is sending a payload to Apple's servers. +Sending a push notification is sending a Payload to Apple's servers. -You may create payloads with APNS::Payload.new(, ) -The payload is composed of a device-token and a message all mixed and encoded together. +You may create payloads with APNS::Payload.new( [,]) +A payload is composed of a device-token and a message all mixed and encoded together. Payload message can either just be a alert string or a hash that lets you specify the alert, badge, sound and any custom field.
   
 device_token = '123abc456def'
-
-APNS.send_payloads(APNS::Payload.new(device_token, 'Hello iPhone!'))
-
-APNS.send_payloads(APNS::Payload.new(device_token, :alert => 'Hello iPhone!', :badge => 1, :sound => 'default'))
-  
-
- -h2. Example (Multiple notifications): - -You can also send multiple payloads at once - -
-  
-device_token = '123abc456def'
-
-p1 = APNS::Payload.new(device_token, 'Hello iPhone!' )
-
+p1 = APNS::Payload.new(device_token, 'Hello iPhone!')
 p2 = APNS::Payload.new(device_token, :alert => 'Hello iPhone!', :badge => 1, :sound => 'default')
+p3 = APNS::Payload.new(device_token).badge(4).alert("Hello iPhone!").sound('bird.aiff')
 
-p3 = APNS::Payload.new(device_token).alert("Hello from APNS").badge(2).sound("bipbip.aiff")
-
-APNS.send_payloads([p1, p2, p3])
+# with custom data:
+p4 = APNS::Payload.new(device_token, :badge => 2, :my_custom_field => 'blah')
+p5 = APNS::Payload.new(device_token, :badge => 2).custom(:my_custom_field => 'blah')
   
 
-h2. Send other info along with aps - -You can send other application specific information as well. - -
-  
-APNS.send_payload(APNS::Payload.new(device_token, :alert => 'Hello iPhone!', :badge => 1, :sound => 'default', :sent => 'with apns gem'))
-  
-
- -This will add the :sent key to the same level as the "aps" key: - -
-  
-{"aps":{"alert":"Hello iPhone!","badge":1,"sound":"default"},"sent":"with apns gem"}
-  
-
- h2. Truncating payload informations -Only valid Payloads will be sent to Apple. A valid payload has a size lesser or equal to 256 bytes. +Only valid Payloads will be sent to Apple. From APNS point of view, a valid payload has a size lesser or equal to 256 bytes. +REM: Apple may find a APNS valid payload invalid because it doesn't contain mandatory fields in the message part. +For instance, a message must at least contain a non empty badge or sound or alert. + You can check whether a payload is valid with the Payload#valid? method. In case you know a payload message field is too large and wish to have it truncated you can either use Payload#payload_with_truncated_alert or a more generic Payload#payload_with_truncated_string_at_keypath. These two method will try to truncate the value of the alert field or any custom field at a keypath. @@ -128,13 +80,10 @@ Truncate the alert field:
   
 p = APNS::Payload.new("a-device-token", "A very long message "*15)
-=> #, @message={:alert=>"A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message"}>
 p.valid?
 => false
 p.size
 => 331
-p.payload_with_truncated_alert
-=> #, @message={:alert=>"A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A very long message A..."}>
 p.payload_with_truncated_alert.size
 => 256
 p.payload_with_truncated_alert.valid?
@@ -147,13 +96,10 @@ Truncate a custom field:
 
 	
 p = APNS::Payload.new("a-device-token", :alert => "Hello from APNS", :custom => {:foo => "Bar "*80})
-=> #, @message={:alert=>"Hello from APNS", :custom=>{:foo=>"Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar "}}>
 p.valid?
 => false
 p.size
 => 387
-p.payload_with_truncated_string_at_keypath "custom.foo"
-=> #, @message={:alert=>"Hello from APNS", :custom=>{:foo=>"Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Bar Ba..."}}>
 p.payload_with_truncated_string_at_keypath("custom.foo").size
 => 256
 p.payload_with_truncated_string_at_keypath("custom.foo").valid?
@@ -162,34 +108,92 @@ p.payload_with_truncated_string_at_keypath("custom.foo").valid?
 
-h2. Getting your iPhone's device token +h2. Sending Notifications to a single application: -After you setup push notification for your application with Apple. You need to ask Apple for you application specific device token. +Before sending notifications, you _must_ have setup the pem file(s) so that Apple knows which application you are sending a notification to. -ApplicationAppDelegate.m
   
-- (void)applicationDidFinishLaunching:(UIApplication *)application {    
-	// Register with apple that this app will use push notification
-	[[UIApplication sharedApplication] registerForRemoteNotificationTypes:(UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeBadge)];	
-}
+APNS.pem = "/path/to/my/development.pem"
+  
+
-- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { - // Show the device token obtained from apple to the log - NSLog(@"deviceToken: %@", deviceToken); -} +Now we can send some payloads either with: + + * APNS.send_payloads() + * APNS.send() # same as APNS.send_payloads + +
+  
+APNS.send(p1, p2, p3)
+  
+
+ +h2. Sending Notifications to multiple applications: + +You may want to handle push notifications for many applications at once. In this case you have to setup multiple pem streams: + +
+  
+@streams = [:voodoo, :child]
+
+@streams.each do |stream|
+	APNS.pem(stream, "/path/to/#{stream}/development.pem"
+end
+  
+
+ +Now you can send the notifications to any stream with: + + * APNS.send_stream(, ) + +
+  
+APNS.send_stream(@streams.first, p1, p2, p3)
+APNS.send_stream(@streams.last, p4, p5)
   
 
-h2. Feedback: +h2. Feedback queue: -You should check the feedback queue of your application on Apple's servers to avoid sending notifications for obsolete devices +You should check the feedback queue of your application on Apple's servers to avoid sending notifications to obsolete devices + +For single pem:
 	
 APNS.feedback.each do |time, token|
-	... do stuff with token
+	# remove the device registered with this token ?
+end
+	
+
+ +For multiple pems: + +
+	
+APNS.feedback(@streams.first).each do |time, token|
+	# remove the device registered with this token ?
 end
 	
 
+ + +h2. Getting your iPhone's device token + +After you setup push notification for your application with Apple. You need to ask Apple for you application specific device token. + +In the UIApplicationDelegate +
+  
+- (void)applicationDidFinishLaunching:(UIApplication *)application {    
+	[[UIApplication sharedApplication] registerForRemoteNotificationTypes:
+		(UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeBadge)];	
+}
+
+- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
+	// Do something with the device token
+}
+  
+
diff --git a/lib/apns.rb b/lib/apns.rb index 2cd6de1..82bbcb6 100644 --- a/lib/apns.rb +++ b/lib/apns.rb @@ -1,3 +1,3 @@ require 'apns/core' require 'apns/device' -require 'apns/payload' +require 'apns/payload' \ No newline at end of file diff --git a/lib/apns/core.rb b/lib/apns/core.rb index 59aa2a4..8d0d921 100644 --- a/lib/apns/core.rb +++ b/lib/apns/core.rb @@ -27,15 +27,26 @@ class PemFileError < RuntimeError;end @feedback_port = 2196 # openssl pkcs12 -in mycert.p12 -out client-cert.pem -nodes -clcerts - @pem = nil # this should be the path of the pem file not the contents - @pass = nil + @pem = {} # this should be the path of the pem file not the contents + @pass = {} # Persistent connection - @@ssl = nil - @@sock = nil + @@ssl = {} + @@sock = {} class << self - attr_accessor :host, :port, :feedback_host, :feedback_port, :pem, :pass + attr_accessor :host, :port, :feedback_host, :feedback_port + def pem(stream = :_global, new_pem = nil) + @pem[stream] = new_pem if new_pem + @pem[stream] + end + def pem=(new_pem); @pem[:_global] = new_pem; end + + def pass(stream = :_global, new_pass = nil) + @pass[stream] = new_pass if new_pass + @pass[stream] + end + def pass=(new_pass); @pass[:_global] = new_pass; end end # send one or many payloads @@ -55,9 +66,10 @@ class << self # or with multiple payloads # APNS.send_payloads([payload1, payload2]) - def self.send_payloads(payloads) - # accept Array or single payload - payloads = payloads.is_a?(Array) ? payloads : [payloads] + + # Send to a pem stream + def self.send_stream(stream, *payloads) + payloads.flatten! # retain valid payloads only payloads.reject!{ |p| !(p.is_a?(APNS::Payload) && p.valid?) } @@ -67,16 +79,17 @@ def self.send_payloads(payloads) # loop through each payloads payloads.each do |payload| retry_delay = 2 + + # !ToDo! do a better job by using a select to poll the socket for a possible response from apple to inform us about an error in the sent payload + # begin - if @@ssl.nil? - @@sock, @@ssl = self.push_connection - end - @@ssl.write(payload.to_ssl); @@ssl.flush + @@sock[stream], @@ssl[stream] = self.push_connection(stream) if @@ssl[stream].nil? + @@ssl[stream].write(payload.to_ssl); @@ssl[stream].flush rescue PemPathError, PemFileError => e raise e rescue - @@ssl.close; @@sock.close - @@ssl = nil; @@sock = nil # cleanup + @@ssl[stream].close; @@sock[stream].close + @@ssl[stream] = nil; @@sock[stream] = nil # cleanup retry_delay *= 2 if retry_delay <= 8 @@ -86,13 +99,21 @@ def self.send_payloads(payloads) raise end end # begin block - + end # each payloads end - - def self.feedback - sock, ssl = self.feedback_connection + def self.send_payloads(*payloads) + self.send(payloads) + end + + def self.send(*payloads) + self.send_stream(:_global, payloads) + end + + + def self.feedback(stream = :_global) + sock, ssl = self.feedback_connection(stream) apns_feedback = [] @@ -111,18 +132,18 @@ def self.feedback protected - def self.ssl_context - raise PemPathError, "The path to your pem file is not set. (APNS.pem = /path/to/cert.pem)" unless self.pem - raise PemFileError, "The path to your pem file does not exist!" unless File.exist?(self.pem) + def self.ssl_context(stream = :_global) + raise PemPathError, "The path to your pem file is not set. (APNS.pem = /path/to/cert.pem)" unless self.pem(stream) + raise PemFileError, "The path to your pem file does not exist!" unless File.exist?(self.pem(stream)) context = OpenSSL::SSL::SSLContext.new - context.cert = OpenSSL::X509::Certificate.new(File.read(self.pem)) - context.key = OpenSSL::PKey::RSA.new(File.read(self.pem), self.pass) + context.cert = OpenSSL::X509::Certificate.new(File.read(self.pem(stream))) + context.key = OpenSSL::PKey::RSA.new(File.read(self.pem(stream)), self.pass(stream)) context end - def self.connect_to(aps_host, aps_port) - context = self.ssl_context + def self.connect_to(aps_host, aps_port, stream = :_global) + context = self.ssl_context(stream) sock = TCPSocket.new(aps_host, aps_port) ssl = OpenSSL::SSL::SSLSocket.new(sock, context) ssl.connect @@ -130,12 +151,12 @@ def self.connect_to(aps_host, aps_port) return sock, ssl end - def self.push_connection - self.connect_to(self.host, self.port) + def self.push_connection(stream = :_global) + self.connect_to(self.host, self.port, stream) end - def self.feedback_connection - self.connect_to(self.feedback_host, self.feedback_port) + def self.feedback_connection(stream = :_global) + self.connect_to(self.feedback_host, self.feedback_port, stream) end end diff --git a/lib/apns/payload.rb b/lib/apns/payload.rb index 1810e15..f93ff55 100644 --- a/lib/apns/payload.rb +++ b/lib/apns/payload.rb @@ -20,10 +20,28 @@ def initialize(device_token, message_string_or_hash = {}) self end + + # Batch payloads + # Ex: APNS::Payload.batch(Device.all.collect{|d|d.token}, :alert => "Hello") + # or with a block + # APNS::Payload.batch(Device.all.collect{|d|d.token}, :alert => custom_big_alert) do |payload| + # payload.payload_with_truncated_alert + # end + def self.batch(device_tokens, message_string_or_hash = {}) + raise unless device_tokens.is_a?(Array) + payloads = [] + device_tokens.each do |device| + payload = self.new(device, message_string_or_hash) + payload = yield(payload) if block_given? + payloads << payload + end + payloads + end + # # Handy chainable setters # - # Ex: APNS::Payload.new(token).badge(3).sound("bipbip").alert("Roadrunner!") + # Ex: APNS::Payload.new(token).badge(3).sound("bipbip").alert("Roadrunner!").custom(:foo => :bar) # def badge(number) message[:badge] = number @@ -40,6 +58,13 @@ def alert(string) self end + def custom(hash) + return nil unless hash.is_a? Hash + return nil if hash.any?{|k,v| APS_KEYS.include?(k.to_sym) || (k.to_sym == APS_ROOT)} + message.merge!(hash) + self + end + # def to_ssl @@ -51,6 +76,7 @@ def size self.to_ssl.size end + # Validity checking only checks that the payload size is valid. We do not check the message content. def valid? self.size <= PAYLOAD_MAX_SIZE end