diff --git a/.rbenv-version b/.rbenv-version new file mode 100644 index 0000000..9bbcf15 --- /dev/null +++ b/.rbenv-version @@ -0,0 +1 @@ +1.9.3-p0 diff --git a/.rvmrc b/.rvmrc deleted file mode 100644 index 062c813..0000000 --- a/.rvmrc +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -# This is an RVM Project .rvmrc file, used to automatically load the ruby -# development environment upon cd'ing into the directory - -# First we specify our desired [@], the @gemset name is optional. -environment_id="ruby-1.8.7-p352@apnd" - -# -# Uncomment following line if you want options to be set only for given project. -# -# PROJECT_JRUBY_OPTS=( --1.9 ) - -# -# First we attempt to load the desired environment directly from the environment -# file. This is very fast and efficient compared to running through the entire -# CLI and selector. If you want feedback on which environment was used then -# insert the word 'use' after --create as this triggers verbose mode. -# -if [[ -d "${rvm_path:-$HOME/.rvm}/environments" \ - && -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]] -then - \. "${rvm_path:-$HOME/.rvm}/environments/$environment_id" - - if [[ -s "${rvm_path:-$HOME/.rvm}/hooks/after_use" ]] - then - . "${rvm_path:-$HOME/.rvm}/hooks/after_use" - fi -else - # If the environment file has not yet been created, use the RVM CLI to select. - if ! rvm --create use "$environment_id" - then - echo "Failed to create RVM environment '${environment_id}'." - exit 1 - fi -fi - -# -# If you use an RVM gemset file to install a list of gems (*.gems), you can have -# it be automatically loaded. Uncomment the following and adjust the filename if -# necessary. -# -# filename=".gems" -# if [[ -s "$filename" ]] -# then -# rvm gemset import "$filename" | grep -v already | grep -v listed | grep -v complete | sed '/^$/d' -# fi - -# If you use bundler, this might be useful to you: -# if command -v bundle && [[ -s Gemfile ]] -# then -# bundle -# fi - - diff --git a/Gemfile b/Gemfile index d65e2a6..817f62a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,2 @@ source 'http://rubygems.org' - gemspec diff --git a/Rakefile b/Rakefile index 7ad25a6..f5b8662 100644 --- a/Rakefile +++ b/Rakefile @@ -1,12 +1,14 @@ $:.unshift 'lib' -task :default => :test +begin + require 'rspec/core/rake_task' +rescue LoadError + puts "Please run `bundle install' first" + exit +end -require 'rake/testtask' -Rake::TestTask.new(:test) do |test| - test.libs << 'lib' << 'test' << '.' - test.test_files = FileList['test/**/*_test.rb'] - test.verbose = true +RSpec::Core::RakeTask.new :spec do |t| + t.rspec_opts = %w[--color --format documentation] end desc "Open an irb session preloaded with this library" @@ -14,7 +16,6 @@ task :console do sh "irb -rubygems -r ./lib/apnd.rb -I ./lib" end -require 'sdoc_helpers' desc "Push a new version to Gemcutter" task :publish do require 'apnd/version' @@ -27,5 +28,6 @@ task :publish do sh "git push origin v#{ver}" sh "git push origin master" sh "git clean -fd" - sh "rake pages" end + +task :default => :spec diff --git a/bin/apnd b/bin/apnd deleted file mode 100755 index f0be2f2..0000000 --- a/bin/apnd +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env ruby - -ARGV << '--help' if ARGV.empty? - -if $0 == __FILE__ - require 'rubygems' - $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) -end - -require 'apnd' - -command = ARGV.shift - -case command -when 'daemon' - APND::CLI.daemon(ARGV) -when 'push' - APND::CLI.push(ARGV) -when '--version', '-v' - puts "APND v#{APND::Version}" -else - puts "Error: Invalid command" unless %w(-h --help).include?(command) - puts <<-HELP -Usage: apnd COMMAND [ARGS] - -Command list: - daemon Start the APND Daemon - push Send a single push notification (for development use only) - -Help: - --version Show version - --help Show this message - HELP -end diff --git a/lib/apnd.rb b/lib/apnd.rb index fe995ca..74e88a0 100644 --- a/lib/apnd.rb +++ b/lib/apnd.rb @@ -1,33 +1,3 @@ -require 'json' - -module APND - autoload :Version, 'apnd/version' - autoload :CLI, 'apnd/cli' - autoload :Errors, 'apnd/errors' - autoload :Settings, 'apnd/settings' - autoload :Daemon, 'apnd/daemon' - autoload :Notification, 'apnd/notification' - autoload :Feedback, 'apnd/feedback' - - # - # Returns APND::Settings - # - def self.settings - @@settings ||= Settings.new - end - - # - # Yields APND::Settings - # - def self.configure - yield settings - end - - # - # Write message to stdout with date - # - def self.logger(message) #:nodoc: - puts "[%s] %s" % [Time.now.strftime("%Y-%m-%d %H:%M:%S"), message] - end - -end +require 'apnd/version' +require 'apnd/hash' +require 'apnd/notification' diff --git a/lib/apnd/cli.rb b/lib/apnd/cli.rb deleted file mode 100644 index 0ce4f04..0000000 --- a/lib/apnd/cli.rb +++ /dev/null @@ -1,195 +0,0 @@ -require 'daemons' -require 'optparse' - -module APND - class CLI #:nodoc: all - - # - # Run apnd push - # - def self.push(argv) - help = <<-HELP -Usage: - apnd push [OPTIONS] --token - - HELP - - options = {} - - opts = OptionParser.new do |opt| - opt.banner = help - - opt.separator "Required Arguments:\n" - - opt.on('--token [TOKEN]', "Set Notification's iPhone token to TOKEN") do |token| - options[:token] = token - end - - opt.separator "\nOptional Arguments:\n" - - opt.on('--alert [MESSAGE]', "Set Notification's alert to MESSAGE") do |alert| - options[:alert] = alert - end - - opt.on('--sound [SOUND]', "Set Notification's sound to SOUND") do |sound| - options[:sound] = sound - end - - opt.on('--badge [NUMBER]', "Set Notification's badge number to NUMBER") do |badge| - options[:badge] = badge.to_i - end - - opt.on('--custom [JSON]', "Set Notification's custom data to JSON") do |custom| - begin - options[:custom] = JSON.parse(custom) - rescue JSON::ParserError => e - puts "Invalid JSON: #{e}" - exit -1 - end - end - - opt.on('--host [HOST]', "Send Notification to HOST, usually the one running APND (default is 'localhost')") do |host| - options[:host] = host - end - - opt.on('--port [PORT]', 'Send Notification on PORT (default is 22195)') do |port| - options[:port] = port.to_i - end - - opt.separator "\nHelp:\n" - - opt.on('--help', 'Show this message') do - puts opt - exit - end - end - - begin - opts.parse! - if options.empty? - puts opts - exit - end - - unless options[:token] - raise OptionParser::MissingArgument, "must specify --token" - end - rescue OptionParser::InvalidOption, OptionParser::MissingArgument - puts "#{$0}: #{$!.message}" - puts "#{$0}: try '#{$0} --help' for more information" - exit - end - - # Configure Notification upstream host/port - APND::Notification.upstream_host = options.delete(:host) if options[:host] - APND::Notification.upstream_port = options.delete(:port) if options[:port] - - APND::Notification.create(options) - end - - # - # Run apnd daemon - # - def self.daemon(argv) - help = <<-HELP -Usage: - apnd daemon --apple-cert - - HELP - - options = {} - - opts = OptionParser.new do |opt| - opt.banner = help - - opt.separator "Required Arguments:\n" - - opt.on('--apple-cert [PATH]', 'PATH to APN certificate from Apple') do |cert| - options[:apple_cert] = cert - end - - opt.separator "\nOptional Arguments:\n" - - opt.on('--apple-host [HOST]', "Connect to Apple at HOST (default is gateway.sandbox.push.apple.com)") do |host| - options[:apple_host] = host - end - - opt.on('--apple-port [PORT]', 'Connect to Apple on PORT (default is 2195)') do |port| - options[:apple_port] = port.to_i - end - - opt.on('--apple-cert-pass [PASSWORD]', 'PASSWORD for APN certificate from Apple') do |pass| - options[:apple_cert_pass] = pass - end - - opt.on('--daemon-port [PORT]', 'Run APND on PORT (default is 22195)') do |port| - options[:daemon_port] = port.to_i - end - - opt.on('--daemon-bind [ADDRESS]', 'Bind APND to ADDRESS (default is 0.0.0.0)') do |bind| - options[:daemon_bind] = bind - end - - opt.on('--daemon-log-file [PATH]', 'PATH to APND log file (default is /var/log/apnd.log)') do |log| - options[:daemon_log_file] = log - end - - opt.on('--daemon-timer [SECONDS]', 'Set APND queue refresh time to SECONDS (default is 30)') do |seconds| - options[:daemon_timer] = seconds.to_i - end - - opt.on('--foreground', 'Run APND in foreground without daemonizing') do - options[:foreground] = true - end - - opt.separator "\nHelp:\n" - - opt.on('--help', 'Show this message') do - puts opt - exit - end - end - - begin - opts.parse! - if options.empty? - puts opts - exit - end - - unless options[:apple_cert] - raise OptionParser::MissingArgument, "must specify --apple-cert" - end - rescue OptionParser::InvalidOption, OptionParser::MissingArgument - puts "#{$0}: #{$!.message}" - puts "#{$0}: try '#{$0} --help' for more information" - exit - end - - APND.configure do |config| - # Setup AppleConnection - config.apple.cert = options[:apple_cert] if options[:apple_cert] - config.apple.cert_pass = options[:apple_cert_pass] if options[:apple_cert_pass] - config.apple.host = options[:apple_host] if options[:apple_host] - config.apple.port = options[:apple_port] if options[:apple_port] - - # Setup Daemon - config.daemon.bind = options[:daemon_bind] if options[:daemon_bind] - config.daemon.port = options[:daemon_port] if options[:daemon_port] - config.daemon.log_file = options[:daemon_log_file] if options[:daemon_log_file] - config.daemon.timer = options[:daemon_timer] if options[:daemon_timer] - end - - if APND.settings.apple.cert.nil? - puts opts - exit - else - unless options[:foreground] - Daemonize.daemonize(APND.settings.daemon.log_file, 'apnd') - end - APND::Daemon.run! - end - - end - end -end diff --git a/lib/apnd/daemon.rb b/lib/apnd/daemon.rb deleted file mode 100644 index 6be0365..0000000 --- a/lib/apnd/daemon.rb +++ /dev/null @@ -1,78 +0,0 @@ -require 'eventmachine' - -module APND - # - # The APND::Daemon maintains a persistent secure connection with Apple, - # (APND::Daemon::AppleConnection). Notifications are queued and periodically - # writen to the AppleConnection - # - class Daemon - autoload :Protocol, 'apnd/daemon/protocol' - autoload :AppleConnection, 'apnd/daemon/apple_connection' - autoload :ServerConnection, 'apnd/daemon/server_connection' - - # - # Create a new Daemon and run it - # - def self.run! - server = APND::Daemon.new - server.run! - end - - # - # Create a connection to Apple and a new EM queue - # - def initialize - @queue = EM::Queue.new - @apple = APND::Daemon::AppleConnection.new - @bind = APND.settings.daemon.bind - @port = APND.settings.daemon.port - @timer = APND.settings.daemon.timer - end - - # - # Run the daemon - # - def run! - EventMachine::run do - APND.logger "Starting APND Daemon v#{APND::Version} on #{@bind}:#{@port}" - EventMachine::start_server(@bind, @port, APND::Daemon::ServerConnection) do |server| - server.queue = @queue - end - - EventMachine::PeriodicTimer.new(@timer) do - process_notifications! - end - end - end - - private - - # - # Sends each notification in the queue upstream to Apple - # - def process_notifications! - count = @queue.size - if count > 0 - APND.logger "Queue has #{count} item#{count == 1 ? '' : 's'}" - @apple.connect! - count.times do - @queue.pop do |notification| - begin - APND.logger "Sending notification for #{notification.token}" - @apple.write(notification.to_bytes) - rescue Errno::EPIPE, OpenSSL::SSL::SSLError - APND.logger "Error, notification has been added back to the queue" - @queue.push(notification) - @apple.reconnect! - rescue RuntimeError => error - APND.logger "Error: #{error}" - end - end - end - @apple.disconnect! - end - end - - end -end diff --git a/lib/apnd/daemon/apple_connection.rb b/lib/apnd/daemon/apple_connection.rb deleted file mode 100644 index 39c171a..0000000 --- a/lib/apnd/daemon/apple_connection.rb +++ /dev/null @@ -1,84 +0,0 @@ -require 'openssl' -require 'socket' - -module APND - # - # Daemon::AppleConnection handles the persistent connection between - # APND and Apple - # - class Daemon::AppleConnection - attr_reader :ssl, :sock - - # - # Setup a new connection - # - def initialize(params = {}) - @options = { - :cert => APND.settings.apple.cert, - :cert_pass => APND.settings.apple.cert_pass, - :host => APND.settings.apple.host, - :port => APND.settings.apple.port.to_i - }.merge(params) - end - - # - # Returns true if the connection to Apple is open - # - def connected? - ! @ssl.nil? - end - - # - # Connect to Apple over SSL - # - def connect! - cert = File.read(@options[:cert]) - context = OpenSSL::SSL::SSLContext.new - context.key = OpenSSL::PKey::RSA.new(cert, @options[:cert_pass]) - context.cert = OpenSSL::X509::Certificate.new(cert) - - @sock = TCPSocket.new(@options[:host], @options[:port]) - @ssl = OpenSSL::SSL::SSLSocket.new(@sock, context) - @ssl.sync = true - @ssl.connect - end - - # - # Close connection - # - def disconnect! - @ssl.close - @sock.close - @ssl = nil - @sock = nil - end - - # - # Disconnect/connect to Apple - # - def reconnect! - disconnect! - connect! - end - - # - # Establishes a connection if needed and yields it - # - # Ex: open { |conn| conn.write('write to socket') } - # - def open(&block) - unless connected? - connect! - end - - yield @ssl - end - - # - # Write to the connection socket - # - def write(raw) - open { |conn| conn.write(raw) } - end - end -end diff --git a/lib/apnd/daemon/protocol.rb b/lib/apnd/daemon/protocol.rb deleted file mode 100644 index 11eadb6..0000000 --- a/lib/apnd/daemon/protocol.rb +++ /dev/null @@ -1,54 +0,0 @@ -require 'socket' - -module APND - # - # Daemon::Protocol handles incoming APNs - # - module Daemon::Protocol - - # - # Called when a client connection is opened - # - def post_init - @address = ::Socket.unpack_sockaddr_in(self.get_peername) - APND.logger "#{@address.last}:#{@address.first} opened connection" - end - - # - # Called when a client connection is closed - # - # Checks @buffer for any pending notifications to be - # queued - # - def unbind - # totally broken. - @buffer.chomp! - while(@buffer.length > 0) do - # 3 bytes for header - # 32 bytes for token - # 2 bytes for json length - - # taking the last is acceptable because we know it's never - # longer than 256 bytes from the apple documentation. - json_length = @buffer.slice(35,37).unpack('CC').last - chunk = @buffer.slice!(0,json_length + 3 + 32 + 2) - if notification = APND::Notification.valid?(chunk) - APND.logger "#{@address.last}:#{@address.first} added new Notification to queue" - queue.push(notification) - else - APND.logger "#{@address.last}:#{@address.first} submitted invalid Notification" - end - @buffer.strip! - end - APND.logger "#{@address.last}:#{@address.first} closed connection" - end - - # - # Add incoming notification(s) to @buffer - # - def receive_data(data) - APND.logger "#{@address.last}:#{@address.first} buffering data" - (@buffer ||= "") << data - end - end -end diff --git a/lib/apnd/daemon/server_connection.rb b/lib/apnd/daemon/server_connection.rb deleted file mode 100644 index a5c1e83..0000000 --- a/lib/apnd/daemon/server_connection.rb +++ /dev/null @@ -1,15 +0,0 @@ -module APND - # - # Daemon::ServerConnection links APND::Daemon::Protocol to EM - # - class Daemon::ServerConnection < ::EventMachine::Connection - - include APND::Daemon::Protocol - - # - # Queue should be the EventMachine queue, see APND::Daemon - # - attr_accessor :queue - - end -end diff --git a/lib/apnd/errors.rb b/lib/apnd/errors.rb deleted file mode 100644 index c41d48a..0000000 --- a/lib/apnd/errors.rb +++ /dev/null @@ -1,22 +0,0 @@ -module APND - module Errors #:nodoc: all - - # - # Raised if APN payload is larger than 256 bytes - # - class InvalidPayload < StandardError - def initialize(message) - super("Payload is larger than 256 bytes: '#{message}'") - end - end - - # - # Raised when parsing a Notification with an invalid header - # - class InvalidNotificationHeader < StandardError - def initialize(header) - super("Invalid Notification header: #{header.inspect}") - end - end - end -end diff --git a/lib/apnd/feedback.rb b/lib/apnd/feedback.rb deleted file mode 100644 index cfa97a6..0000000 --- a/lib/apnd/feedback.rb +++ /dev/null @@ -1,65 +0,0 @@ -module APND - # - # APND::Feedback receives feedback from Apple when notifications are - # being rejected for a specific token. This is usually due to the user - # uninstalling your application. - # - class Feedback - - class << self - # - # The host to receive feedback from, usually apple - # - attr_accessor :upstream_host - - # - # The port to connect to upstream_host on - # - attr_accessor :upstream_port - end - - # - # Set upstream host/port to default values - # - self.upstream_host = APND.settings.feedback.host - self.upstream_port = APND.settings.feedback.port.to_i - - # - # Connect to Apple's Feedback Service and return an array of device - # tokens that should no longer receive push notifications - # - def self.find_stale_devices(&block) - feedback = self.new - devices = feedback.receive - devices.each { |device| yield *device } if block_given? - devices - end - - # - # Create a connection to Apple's Feedback Service - # - def initialize - @apple = APND::Daemon::AppleConnection.new({ - :cert => APND.settings.apple.cert, - :cert_pass => APND.settings.apple.cert_pass, - :host => self.class.upstream_host, - :port => self.class.upstream_port.to_i - }) - end - - # - # Receive feedback from Apple and return an array of device tokens - # - def receive - tokens = [] - @apple.open do |sock| - while line = sock.gets - payload = line.strip.unpack('N1n1H140') - tokens << [payload[2].strip, Time.at(payload[0])] - end - end - tokens - end - - end -end diff --git a/lib/apnd/hash.rb b/lib/apnd/hash.rb new file mode 100644 index 0000000..27e4004 --- /dev/null +++ b/lib/apnd/hash.rb @@ -0,0 +1,11 @@ +class Hash + # Public: Returns a copy of Hash with all keys set as symbols. If values are + # Hashes, they are traversed and converted as well. + def deep_symbolize + inject({}) do |hash, (key, val)| + val = val.deep_symbolize if val.respond_to?(:deep_symbolize) + hash[key.to_sym] = val + hash + end + end unless respond_to?(:deep_symbolize) +end diff --git a/lib/apnd/notification.rb b/lib/apnd/notification.rb index 03847c9..872ab28 100644 --- a/lib/apnd/notification.rb +++ b/lib/apnd/notification.rb @@ -1,187 +1,23 @@ -require 'socket' +require 'apnd/notification/simple' +require 'apnd/notification/enhanced' module APND - # - # APND::Notification is the base class for creating new push notifications. - # - class Notification - - class << self - # - # The host notifications will be written to, usually one - # running APND - # - attr_accessor :upstream_host - - # - # The port to connect to upstream_host on - # - attr_accessor :upstream_port - end - - # - # Set upstream host/port to default values - # - self.upstream_host = APND.settings.notification.host - self.upstream_port = APND.settings.notification.port.to_i - - # - # The device token from Apple - # - attr_accessor :token - - # - # The alert to send - # - attr_accessor :alert - - # - # The badge number to set - # - attr_accessor :badge - - # - # The sound to play - # - attr_accessor :sound - - # - # Custom data to send - # - attr_accessor :custom - - # - # Creates a new socket to upstream_host:upstream_port - # - def self.upstream_socket - @socket = TCPSocket.new(upstream_host, upstream_port) - end - - # - # Opens a new socket upstream, yields it, and closes it - # - def self.open_upstream_socket(&block) - socket = upstream_socket - yield socket - socket.close - end - - # - # Create a new APN - # - def self.create(params = {}, push = true) - notification = Notification.new(params) - notification.push! if push - notification - end - - # - # Try to create a new Notification from raw data - # Used by Daemon::Protocol to validate incoming data - # - def self.valid?(data) - parse(data) - rescue - false - end - - # - # Parse raw data into a new Notification - # - def self.parse(data) - buffer = data.dup - notification = Notification.new - - header = buffer.slice!(0, 3).unpack('ccc') - - if header[0] != 0 - raise APND::Errors::InvalidNotificationHeader.new(header) - end - - notification.token = buffer.slice!(0, 32).unpack('H*').first - - json_length = buffer.slice!(0, 2).unpack('CC') - - json = buffer.slice!(0, json_length.last) - - payload = JSON.parse(json) - - %w[alert sound badge].each do |key| - if payload['aps'] && payload['aps'][key] - notification.send("#{key}=", payload['aps'][key]) - end - end - - payload.delete('aps') - - unless payload.empty? - notification.custom = payload - end - - notification - end - - # - # Create a new Notification object from a hash - # - def initialize(params = {}) - @token = params[:token] - @alert = params[:alert] - @badge = params[:badge] - @sound = params[:sound] - @custom = params[:custom] - end - - # - # Token in hex format - # - def hex_token - [self.token.delete(' ')].pack('H*') - end - - # - # aps hash sent to Apple - # - def aps - aps = {} - aps['alert'] = self.alert if self.alert - aps['badge'] = self.badge.to_i if self.badge - aps['sound'] = self.sound if self.sound - - output = { 'aps' => aps } - - if self.custom - self.custom.each do |key, value| - output[key.to_s] = value - end + module Notification + # Public: Parses a raw notification. This can be used to determine if a + # binary string is a simple notification, enhanced notification, or + # feedback from Apple. + # + # string - Binary content representing the notification. + # + # Returns a new Simple/Enhanced/Feedback Notification object depending on + # the string. + def self.parse(string) + case string[0, 1].unpack('C').first + when 0; Simple.parse string + when 1; Enhanced.parse string + else + raise "Invalid notification!" end - output - end - - # - # Pushes notification to upstream host:port (default is localhost:22195) - # - def push! - self.class.open_upstream_socket { |sock| sock.write(to_bytes) } - end - - # - # Returns the Notification's aps hash as json - # - def aps_json - return @aps_json if @aps_json - json = aps.to_json - raise APND::Errors::InvalidPayload.new(json) if json.size > 256 - @aps_json = json end - - # - # Format the notification as a string for submission - # to Apple - # - def to_bytes - @bytes ||= "\0\0 %s\0%s%s" % [hex_token, aps_json.length.chr, aps_json] - end - end end diff --git a/lib/apnd/notification/enhanced.rb b/lib/apnd/notification/enhanced.rb new file mode 100644 index 0000000..3fcdb1d --- /dev/null +++ b/lib/apnd/notification/enhanced.rb @@ -0,0 +1,127 @@ +module APND + module Notification + class Enhanced < Simple + attr_accessor :identifier + attr_accessor :expiry + + # Public: Parse a binary string into a new Enhanced notification. + # + # string - Binary content representing the notification. + # + # Example: + # + # n = Notification::Enhanced.parse "RAW_PACKET" + # + # Returns a new Enhanced notification object if the string was a valid + # notification, otherwise returns nil. + def self.parse(string) + command = string.slice!(0, 1).unpack('C').first + identifier = string.slice!(0, 4).unpack('A4').first + expiry = string.slice!(0, 4).unpack('N').first + token_length = string.slice!(0, 2).unpack('n').first + token = string.slice!(0, token_length).unpack('H*').first + json_length = string.slice!(0, 2).unpack('n').first + json = string.slice!(0, json_length) + + return unless command == 1 + + payload = JSON.parse(json).deep_symbolize + + params = payload.delete(:aps) + params.merge!( + :token => token, + :identifier => identifier, + :expiry => expiry + ) + params.merge!(payload) unless payload.empty? + + self.new(params) + end + + # Initializes a new Enhanced notification object. + # + # params - An optional Hash containing data for the notification. The + # following keys are special, and are set as instance variables: + # + # :token - The iOS device token as a hex string. + # :alert - String alert text to be sent to the user. + # :badge - The Integer badge count. Use 0 to clear. + # :sound - The String sound file to use. The file must be + # present in your app. + # :identifier - Arbitrary value used to identify this + # notification. Apple will send this identifier + # with an error response if they cannot process + # a notification. + # + # Any remaining parameters are set to @extra. + def initialize(params = {}) + super + + params = @extra.dup.deep_symbolize + @identifier = params.delete :identifier + @expiry = params.delete :expiry + @extra = params.dup + end + + # Public: Validate this Enhanced notification. + # + # A valid Enhanced notification: + # * must have a valid expiry + # * must have a valid identifier + # * must have a valid token + # * must have at lease one of alert, badge, or sound + # * must not exceed 256 bytes + # + # Returns true if the notification is valid, false if not. + def valid? + valid_expiry? && valid_identifier? && super + end + + # Public: Create an Enhanced notification packet. This packet is + # suitable to be sent to either Apple or an APND Daemon. + # + # Enhanced notifications are binary content made up of the following + # + # 1 byte: command (always 1, this indicates an enhanced notification) + # 4 bytes: identifier for this notification (arbitrary value returned if errors occur) + # 4 bytes: expiry as UNIX timestmap (big endian, network order) + # 2 bytes: device token length (big endian, network order), usually [0, 32] + # 32 bytes: device token + # 2 bytes: payload length (the length of the APS hash and any additional content as JSON) + # 211 bytes: the notification payload as JSON. Cannot exceed 211 bytes + # + # Returns a string in binary format. + def to_bytes + [1, identifier, expiry, hex_token.bytesize, hex_token, json_payload.bytesize, json_payload].pack('CA4NnA*nA*') + end + + private + + # Private: Validate expiry. + # + # A valid expiry: + # * must be present + # * must be able to convert it into a Time object + # * must be in the future + # * must be 4 bytes when converted to big endian + # + # Returns true if the expiry is valid, false if not. + def valid_expiry? + expiry && Time.at(expiry) > Time.now && [expiry].pack('N').bytesize == 4 + rescue TypeError + false + end + + # Private: Validate identifier. + # + # A valid identifier: + # * must be present + # * must be between 1 and 4 bytes + # + # Returns true if the identifier is valid, false if not. + def valid_identifier? + identifier && identifier.bytesize >= 1 && identifier.bytesize <= 4 + end + end # end class Enhanced + end +end diff --git a/lib/apnd/notification/simple.rb b/lib/apnd/notification/simple.rb new file mode 100644 index 0000000..c1e837d --- /dev/null +++ b/lib/apnd/notification/simple.rb @@ -0,0 +1,131 @@ +require 'json' + +module APND + module Notification + # See http://bit.ly/iCdRmd for info about the Apple Push Notification Service + class Simple + # The maximum number of bytes allowed for a notification. + MAXIMUM_PAYLOAD_BYTES = 256 + + # Public: Get or set the iOS Device token. + attr_accessor :token + + # Public: Get or set the alert text. + attr_accessor :alert + + # Public: Get or set the alert badge count. + attr_accessor :badge + + # Public: Get or set the sound. + attr_accessor :sound + + # Public: Get extra data in the notification. This includes everything + # other than the attr_accessors listed above. + attr_reader :extra + + # Public: Parse a binary string into a new Notification. + # + # string - Binary content representing the notification. + # + # Example: + # + # n = Notification::Simple.parse "RAW_PACKET" + # + # Returns a new Notification object if the string was a valid + # notification, otherwise returns nil. + def self.parse(string) + command = string.slice!(0, 1).unpack('C').first + token_length = string.slice!(0, 2).unpack('n').first + token = string.slice!(0, token_length).unpack('H*').first + json_length = string.slice!(0, 2).unpack('n').first + json = string.slice!(0, json_length) + + return unless command == 0 + + payload = JSON.parse(json).deep_symbolize + + params = payload.delete(:aps) + params.merge!(:token => token) + params.merge!(payload) unless payload.empty? + + self.new(params) + end + + # Initializes a new Simple notification object. + # + # params - An optional Hash containing data for the notification. The + # following keys are special, and are set as instance variables: + # + # :token - The iOS device token as a hex string. + # :alert - String alert text to be sent to the user. + # :badge - The Integer badge count. Use 0 to clear. + # :sound - The String sound file to use. The file must be + # present in your app. + # + # Any remaining parameters are set to @extra. + def initialize(params = {}) + params = params.dup.deep_symbolize + @token = params.delete :token + @alert = params.delete :alert + @badge = params.delete :badge + @sound = params.delete :sound + @extra = params + end + + # Public: Validate this Simple notification. + # + # A valid Simple notification: + # * must have a valid token + # * must have at lease one of alert, badge, or sound + # * must not exceed 256 bytes + # + # Returns true if the notification is valid, false if not. + def valid? + token && [alert, sound, badge].any? && to_bytes.bytesize <= 256 + end + + # Public: Create a Simple notification packet. This packet is suitable to + # be sent to either Apple or an APND Daemon. + # + # Simple notifications are binary content made up of the following: + # + # 1 byte: command (always 0, this indicates a simple notification) + # 2 bytes: device token length (big endian, network order), usually [0, 32] + # 32 bytes: device token + # 2 bytes: payload length (the length of the APS hash and any additional content as JSON) + # 218 bytes: the notification payload as JSON. Cannot exceed 218 bytes + # + # Returns a String. + def to_bytes + [0, hex_token.bytesize, hex_token, json_payload.bytesize, json_payload].pack('CnA*nA*') + end + + # Public: The Notification payload as a Hash. + # + # Returns a Hash. + def payload + content = {}.tap do |a| + a[:alert] = alert if alert + a[:badge] = badge if badge + a[:sound] = sound if sound + end + aps = { :aps => content } + aps.merge!(extra) unless extra.empty? + aps + end + + # Public: The Notification payload parsed as JSON. + # + # Returns a String. + def json_payload + payload.to_json + end + + private + + def hex_token + [token].pack('H*') + end + end # end class APND::Notification::Simple + end +end diff --git a/lib/apnd/settings.rb b/lib/apnd/settings.rb deleted file mode 100644 index 82c0bb0..0000000 --- a/lib/apnd/settings.rb +++ /dev/null @@ -1,205 +0,0 @@ -module APND - # - # Settings for APND - # - class Settings - - # - # Settings for APND::Daemon::AppleConnection - # - class AppleConnection - - # - # Host used to connect to Apple - # - # Development: gateway.sandbox.push.apple.com - # Production: gateway.push.apple.com - # - attr_accessor :host - - # - # Port used to connect to Apple - # - attr_accessor :port - - # - # Path to APN cert for your application - # - attr_accessor :cert - - # - # Password for APN cert, optional - # - attr_accessor :cert_pass - - def initialize - @host = 'gateway.sandbox.push.apple.com' - @port = 2195 - end - end - - # - # Settings for APND::Daemon - # - class Daemon - - # - # IP to bind APND::Daemon to - # - # Default: '0.0.0.0' - # - attr_accessor :bind - - # - # Port APND::Daemon will run on - # - # Default: 22195 - # - attr_accessor :port - - # - # Path to APND::Daemon log - # - # Default: /var/log/apnd.log - # - attr_accessor :log_file - - # - # Interval (in seconds) the queue will be processed - # - # Default: 30 - # - attr_accessor :timer - - def initialize - @timer = 30 - @bind = '0.0.0.0' - @port = 22195 - @log_file = '/var/log/apnd.log' - end - end - - # - # Settings for APND::Notification - # - class Notification - - # - # Host to send notification to, usually the one running APND::Daemon - # - # Default: localhost - # - attr_accessor :host - - # - # Port to send notifications to - # - # Default: 22195 - # - attr_accessor :port - - def initialize - @host = 'localhost' - @port = 22195 - end - end - - # - # Settings for APND::Feedback - # - class Feedback - - # - # Host used to connect to Apple - # - # Development: feedback.sandbox.push.apple.com - # Production: feedback.push.apple.com - # - attr_accessor :host - - # - # Port used to connect to Apple - # - # Default: 2196 - # - attr_accessor :port - - def initialize - @host = 'feedback.sandbox.push.apple.com' - @port = 2196 - end - end - - # - # Returns the AppleConnection settings - # - def apple - @apple ||= APND::Settings::AppleConnection.new - end - - # - # Mass assign AppleConnection settings - # - def apple=(options = {}) - if options.respond_to?(:keys) - apple.cert = options[:cert] if options[:cert] - apple.cert_pass = options[:cert_pass] if options[:cert_pass] - apple.host = options[:host] if options[:host] - apple.port = options[:port] if options[:port] - end - end - - # - # Returns the Daemon settings - # - def daemon - @daemon ||= APND::Settings::Daemon.new - end - - # - # Mass assign Daemon settings - # - def daemon=(options = {}) - if options.respond_to?(:keys) - daemon.bind = options[:bind] if options[:bind] - daemon.port = options[:port] if options[:port] - daemon.log_file = options[:log_file] if options[:log_file] - daemon.timer = options[:timer] if options[:timer] - end - end - - # - # Returns the Notification settings - # - def notification - @notification ||= APND::Settings::Notification.new - end - - # - # Mass assign Notification settings - # - def notification=(options = {}) - if options.respond_to?(:keys) - notification.port = options[:port] if options[:port] - notification.host = options[:host] if options[:host] - end - end - - # - # Returns the Feedback settings - # - def feedback - @feedback ||= APND::Settings::Feedback.new - end - - # - # Mass assign Feedback settings - # - def feedback=(options = {}) - if options.respond_to?(:keys) - feedback.port = options[:port] if options[:port] - feedback.host = options[:host] if options[:host] - end - end - end -end diff --git a/lib/apnd/version.rb b/lib/apnd/version.rb index 0ef921f..7f12edf 100644 --- a/lib/apnd/version.rb +++ b/lib/apnd/version.rb @@ -1,11 +1,3 @@ module APND - class Version #:nodoc: - MAJOR = 0 - MINOR = 2 - TINY = 0 - - def self.to_s - [MAJOR, MINOR, TINY].join('.') - end - end + VERSION = Version = '0.3.0' end diff --git a/spec/apnd/hash_spec.rb b/spec/apnd/hash_spec.rb new file mode 100644 index 0000000..fe58dd6 --- /dev/null +++ b/spec/apnd/hash_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe "APND hash" do + let :test_hash do + { 'one' => { 'two' => { 'three' => :three } } } + end + + subject do + test_hash + end + + it "symbolizes keys recursively" do + subject.deep_symbolize.should == + { :one => { :two => { :three => :three } } } + end + + it "does not mutate the original hash" do + subject.deep_symbolize.should_not eq(test_hash) + end +end diff --git a/spec/apnd/notification/enhanced_spec.rb b/spec/apnd/notification/enhanced_spec.rb new file mode 100644 index 0000000..f4eed3e --- /dev/null +++ b/spec/apnd/notification/enhanced_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe APND::Notification::Enhanced do + let!(:params) do + { + :token => 'fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2', + :alert => 'Red Alert, Numba One!', + :sound => :default, + :badge => 10, + :expiry => Time.now.to_i + 3600, + :identifier => 'ABCD', + :location => 'New York' + } + end + + subject do + APND::Notification::Enhanced.new(params) + end + + its(:class) { should be < APND::Notification::Simple } + + describe "Accessors" do + it { should have_attr_accessor :identifier } + it { should have_attr_accessor :expiry } + end + + describe "#initialize" do + pending + end + + describe "#valid?" do + include_examples "#valid?" + it "validates token length" + it "validates identifier length" + end + + describe "#to_bytes" do + + end +end diff --git a/spec/apnd/notification/simple_spec.rb b/spec/apnd/notification/simple_spec.rb new file mode 100644 index 0000000..0ce979c --- /dev/null +++ b/spec/apnd/notification/simple_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +describe APND::Notification::Simple do + let(:hex_token) do + "\xFE\x15\xA2}]\xF3\xC3Gx\xDE\xFB\x1FO8\x80&\\\xC5,\f\x04v\x82\";\xE5\x9F\xB6\x85\x00\xA9\xA2" + end + + let!(:params) do + { + :token => 'fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2', + :alert => 'Red Alert, Numba One!', + :sound => :default, + :badge => 10, + :location => 'New York' + } + end + + subject do + APND::Notification::Simple.new(params) + end + + describe "MAXIMUM_PAYLOAD_BYTES" do + it { APND::Notification::Simple::MAXIMUM_PAYLOAD_BYTES.should == 256 } + end + + describe "Accessors" do + it { should have_attr_accessor :token } + it { should have_attr_accessor :alert } + it { should have_attr_accessor :badge } + it { should have_attr_accessor :sound } + it { should have_attr_reader :extra } + end + + describe "#initialize with params hash" do + [:token, :alert, :badge, :sound].each do |param| + it "assigns params[:#{param}] to @#{param}" do + subject.instance_variable_get("@#{param}").should == params[param] + end + end + + it "assigns extra params to @extra" do + not_extras = [:token, :alert, :badge, :sound] + extra_params = params.inject({}) do |hash, (key, val)| + if not_extras.include?(key) + hash + else + hash.merge(key => val) + end + end + + subject_extra = subject.instance_variable_get(:@extra) + + extra_params.each do |key, val| + subject_extra.should have_key(key) + subject_extra[key].should == extra_params[key] + end + + not_extras.each do |key| + subject.instance_variable_get(:@extra).should_not have_key(key) + end + end + end + + describe "#to_bytes" do + pending + end + + describe "#valid?" do + include_examples "#valid?" + end + + context "Private methods" do + describe "#hex_token" do + it { subject.send(:hex_token).should == hex_token } + end + end + +end diff --git a/spec/apnd/notification_spec.rb b/spec/apnd/notification_spec.rb new file mode 100644 index 0000000..98d4729 --- /dev/null +++ b/spec/apnd/notification_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe APND::Notification do + def should_parse(klass, string) + APND::Notification.const_get(klass).should_receive(:parse).with(string) + APND::Notification.parse string + end + + describe "::parse" do + it "parses a Simple packet" do + should_parse :Simple, [0].pack('C') + end + + it "parses an Enhanced packet" do + should_parse :Enhanced, [1].pack('C') + end + + it "parses an Enhanced packet", :pending => true do + should_parse :Feedback, [8].pack('C') + end + end +end diff --git a/spec/apnd_spec.rb b/spec/apnd_spec.rb new file mode 100644 index 0000000..38447bb --- /dev/null +++ b/spec/apnd_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' + +describe APND do + describe "::Version" do + it "has a valid version" do + APND::Version.should match /\d+\.\d+\.\d+/ + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..18b4a54 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,69 @@ +require 'rspec' +require 'apnd' + +RSpec::Matchers.define :have_attr_accessor do |attribute| + match do |object| + object.respond_to?(attribute) && object.respond_to?("#{attribute}=") + end + + description do + "have attr_accessor :#{attribute}" + end +end + +RSpec::Matchers.define :have_attr_reader do |attribute| + match do |object| + object.respond_to? attribute + end + + description do + "have attr_reader :#{attribute}" + end +end + +RSpec::Matchers.define :have_attr_writer do |attribute| + match do |object| + object.respond_to? "#{attribute}=" + end + + description do + "have attr_writer :#{attribute}" + end +end + + + + shared_examples "#valid?" do + context "requires @token" do + before { subject.token = nil } + its(:valid?) { should be_false } + end + + context "requires one @alert, @badge, or @sound" do + context "with none set" do + before { subject.alert = subject.badge = subject.sound = nil } + its(:valid?) { should be_false } + end + + context "with @alert set" do + before { subject.badge = subject.sound = nil } + its(:valid?) { should be_true } + end + + context "with @badge set" do + before { subject.alert = subject.sound = nil } + its(:valid?) { should be_true } + end + + context "with @sound set" do + before { subject.alert = subject.badge = nil } + its(:valid?) { should be_true } + end + end + + context "requires payload to be <= 256 bytes" do + before { subject.alert = "Alert! " * 100 } + its(:valid?) { should be_false } + end + end + diff --git a/test/apnd_test.rb b/test/apnd_test.rb deleted file mode 100644 index 3716ec0..0000000 --- a/test/apnd_test.rb +++ /dev/null @@ -1,106 +0,0 @@ -require File.dirname(__FILE__) + '/test_helper.rb' - -class APNDTest < Test::Unit::TestCase - # FIXME: Tests fail if the hash keys here arent in this order. It shouldn't - # be so fragile. - @@bytes = %|\000\000 \376\025\242}]\363\303Gx\336\373\037O8\200&\\\305,\f\004v\202\";\345\237\266\205\000\251\242\000\\{\"location\":\"New York\",\"aps\":{\"badge\":10,\"sound\":\"default\",\"alert\":\"Red Alert, Numba One!\"}}| - - context "APND Notification" do - setup do - @notification = APND::Notification.new({ - :token => 'fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2', - :alert => 'Red Alert, Numba One!', - :sound => 'default', - :badge => 10, - :custom => { 'location' => 'New York' } - }) - end - - should "allow initialization with options hash" do - [:token, :alert, :sound, :badge, :custom].each do |key| - assert_not_nil @notification.send(key) - end - end - - should "parse a raw packet" do - notification = APND::Notification.parse(@@bytes) - - assert notification - - [:alert, :badge, :custom, :sound, :token, :hex_token, :to_bytes, :aps, :aps_json].each do |key| - assert_equal @notification.send(key), notification.send(key) - end - end - - should "raise InvalidPayload if custom hash is too large" do - assert_raise APND::Errors::InvalidPayload do - notification = @notification.dup - notification.custom = { - 'lorem' => "Hi! " * 200 - } - APND::Notification.parse(notification.to_bytes) - end - end - - context "instances" do - should "return a valid hex_token" do - expected = %|\376\025\242}]\363\303Gx\336\373\037O8\200&\\\305,\f\004v\202";\345\237\266\205\000\251\242| - assert_equal @notification.hex_token, expected - end - - should "return a valid byte string" do - assert_equal @notification.to_bytes, @@bytes - end - end - - - - end - - context "APND Daemon" do - context "Protocol" do - setup do - @daemon = TestDaemon.new - end - - should "add valid notification to queue" do - @daemon.receive_data(@@bytes) - @daemon.unbind - assert_equal 1, @daemon.queue.size - end - - should "receive multiple Notifications in a single packet" do - @daemon.receive_data([@@bytes, @@bytes, @@bytes].join("\n")) - @daemon.unbind - assert_equal 3, @daemon.queue.size - end - - should "raise InvalidNotificationHeader parsing a bad packet" do - assert_raise APND::Errors::InvalidNotificationHeader do - APND::Notification.parse("I'm not a packet!") - end - assert_equal 0, @daemon.queue.size - end - - - context "newlines" do - should "be able to parse a notification with an embedded newline character" do - @newline_notification = APND::Notification.new({ - # :token => 'fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2', - :token => '74b2a2197d7727a70f939de05a4c7fe8bd4a7d960a77ef4701a80cb7b293ee23', - :alert => 'Red Alert, Numba One!', - :sound => 'default', - :badge => 10, - :custom => { 'location' => 'New York' } - }) - @daemon.receive_data(@newline_notification.to_bytes) - @daemon.unbind - assert_equal 1, @daemon.queue.size - - end - end - - end - end - -end diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index 216901d..0000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'rubygems' -require 'test/unit' -require 'shoulda-context' - -begin - require 'turn' -rescue LoadError -end - -require 'apnd' - -class TestDaemon - include APND::Daemon::Protocol - - def initialize - @queue = [] - @address = [123, '10.10.10.1'] - end - - def queue - @queue - end - -end - -# Silence APND.logger in testing -def APND.logger(*args); end