apn_client 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +6 -0
- data/.rspec +2 -0
- data/.rvmrc +66 -0
- data/Gemfile +4 -0
- data/README.md +88 -0
- data/Rakefile +5 -0
- data/apn_client.gemspec +25 -0
- data/lib/apn_client/connection.rb +64 -0
- data/lib/apn_client/delivery.rb +147 -0
- data/lib/apn_client/message.rb +85 -0
- data/lib/apn_client/named_args.rb +25 -0
- data/lib/apn_client/version.rb +3 -0
- data/lib/apn_client.rb +5 -0
- data/spec/connection_spec.rb +118 -0
- data/spec/delivery_spec.rb +136 -0
- data/spec/message_spec.rb +60 -0
- data/spec/named_args_spec.rb +94 -0
- data/spec/spec_helper.rb +5 -0
- metadata +115 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# This is an RVM Project .rvmrc file, used to automatically load the ruby
|
4
|
+
# development environment upon cd'ing into the directory
|
5
|
+
|
6
|
+
# First we specify our desired <ruby>[@<gemset>], the @gemset name is optional.
|
7
|
+
environment_id="ruby-1.9.2-p180@apn_client"
|
8
|
+
|
9
|
+
#
|
10
|
+
# Uncomment following line if you want options to be set only for given project.
|
11
|
+
#
|
12
|
+
# PROJECT_JRUBY_OPTS=( --1.9 )
|
13
|
+
|
14
|
+
#
|
15
|
+
# First we attempt to load the desired environment directly from the environment
|
16
|
+
# file. This is very fast and efficient compared to running through the entire
|
17
|
+
# CLI and selector. If you want feedback on which environment was used then
|
18
|
+
# insert the word 'use' after --create as this triggers verbose mode.
|
19
|
+
#
|
20
|
+
if [[ -d "${rvm_path:-$HOME/.rvm}/environments" \
|
21
|
+
&& -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]]
|
22
|
+
then
|
23
|
+
\. "${rvm_path:-$HOME/.rvm}/environments/$environment_id"
|
24
|
+
|
25
|
+
if [[ -s "${rvm_path:-$HOME/.rvm}/hooks/after_use" ]]
|
26
|
+
then
|
27
|
+
. "${rvm_path:-$HOME/.rvm}/hooks/after_use"
|
28
|
+
fi
|
29
|
+
else
|
30
|
+
# If the environment file has not yet been created, use the RVM CLI to select.
|
31
|
+
if ! rvm --create "$environment_id"
|
32
|
+
then
|
33
|
+
echo "Failed to create RVM environment '${environment_id}'."
|
34
|
+
return 1
|
35
|
+
fi
|
36
|
+
fi
|
37
|
+
|
38
|
+
#
|
39
|
+
# If you use an RVM gemset file to install a list of gems (*.gems), you can have
|
40
|
+
# it be automatically loaded. Uncomment the following and adjust the filename if
|
41
|
+
# necessary.
|
42
|
+
#
|
43
|
+
# filename=".gems"
|
44
|
+
# if [[ -s "$filename" ]]
|
45
|
+
# then
|
46
|
+
# rvm gemset import "$filename" | grep -v already | grep -v listed | grep -v complete | sed '/^$/d'
|
47
|
+
# fi
|
48
|
+
|
49
|
+
# If you use bundler, this might be useful to you:
|
50
|
+
# if [[ -s Gemfile ]] && ! command -v bundle >/dev/null
|
51
|
+
# then
|
52
|
+
# printf "The rubygem 'bundler' is not installed. Installing it now.\n"
|
53
|
+
# gem install bundler
|
54
|
+
# fi
|
55
|
+
# if [[ -s Gemfile ]] && command -v bundle
|
56
|
+
# then
|
57
|
+
# bundle install
|
58
|
+
# fi
|
59
|
+
|
60
|
+
if [[ $- == *i* ]] # check for interactive shells
|
61
|
+
then
|
62
|
+
echo "Using: $(tput setaf 2)$GEM_HOME$(tput sgr0)" # show the user the ruby and gemset they are using in green
|
63
|
+
else
|
64
|
+
echo "Using: $GEM_HOME" # don't use colors in interactive shells
|
65
|
+
fi
|
66
|
+
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
# APN Client RubyGem
|
2
|
+
|
3
|
+
## Introduction
|
4
|
+
|
5
|
+
This is a RubyGem that allows sending of Apple Push Notifications to iOS devices (i.e. iPhones, iPads) from Ruby. The main features are:
|
6
|
+
|
7
|
+
* Broadcasting of notifications to a large number of devices in a reliable fashion
|
8
|
+
* Dealing with errors (via the enhanced format Apple protocol) when sending notifications
|
9
|
+
* Reading from the Apple Feedback Service to avoid sending to devices with uninstalled applications
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
### 1. Configure the Connection
|
14
|
+
|
15
|
+
```
|
16
|
+
|
17
|
+
ApnClient::Delivery.connection_config = {
|
18
|
+
:host => 'gateway.push.apple.com', # For sandbox, use: gateway.sandbox.push.apple.com
|
19
|
+
:port => 2195,
|
20
|
+
:certificate => IO.read("my_apn_certificate.pem"),
|
21
|
+
:certificate_passphrase => '',
|
22
|
+
}
|
23
|
+
|
24
|
+
ApnClient::Feedback.connection_config = {
|
25
|
+
:host => 'feedback.push.apple.com', # For sandbox, use: feedback.sandbox.push.apple.com
|
26
|
+
:port => 2196,
|
27
|
+
:certificate => IO.read("my_apn_certificate.pem"),
|
28
|
+
:certificate_passphrase => '',
|
29
|
+
}
|
30
|
+
|
31
|
+
```
|
32
|
+
|
33
|
+
### 2. Deliver Your Message
|
34
|
+
|
35
|
+
```
|
36
|
+
message1 = ApnClient::Message.new(1,
|
37
|
+
:device_token => "7b7b8de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d099",
|
38
|
+
:alert => "New version of the app is out. Get it now in the app store!",
|
39
|
+
:badge => 2
|
40
|
+
)
|
41
|
+
message2 = ApnClient::Message.new(2,
|
42
|
+
:device_token => "6a5g4de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d044",
|
43
|
+
:alert => "New version of the app is out. Get it now in the app store!",
|
44
|
+
:badge => 1
|
45
|
+
)
|
46
|
+
delivery = ApnClient::Delivery.new([message1, message2],
|
47
|
+
:callbacks => {
|
48
|
+
:on_write => lambda { |d, m| puts "Wrote message #{m}" },
|
49
|
+
:on_exception => lambda { |d, m, e| puts "Exception #{e} raised when delivering message #{m}" },
|
50
|
+
:on_failure => lambda { |d, m| puts "Skipping failed message #{m}" },
|
51
|
+
:on_error => lambda { |d, message_id, error_code| puts "Received error code #{error_code} from Apple for message #{message_id}" }
|
52
|
+
},
|
53
|
+
:consecutive_failure_limit => 10, # If more than 10 devices in a row fail, we abort the whole delivery
|
54
|
+
:exception_limit => 3 # If a device raises an exception three times in a row we fail/skip the device and move on
|
55
|
+
)
|
56
|
+
delivery.process!
|
57
|
+
puts "Delivered successfully to #{delivery.success_count} out of #{delivery.total_count} devices in #{delivery.elapsed} seconds"
|
58
|
+
```
|
59
|
+
|
60
|
+
One potential gotcha to watch out for is that the device token for a message is per device and per application. This means
|
61
|
+
that different apps on the same device will have different tokens. The Apple documentation uses phone numbers as an analogy
|
62
|
+
to explain what a device token is.
|
63
|
+
|
64
|
+
### 3. Check for Feedback
|
65
|
+
|
66
|
+
TODO
|
67
|
+
|
68
|
+
## Dependencies
|
69
|
+
|
70
|
+
The payload of an APN message is a JSON formated hash (containing alert message, badge count, content available etc.) and therefore a JSON library needs to be present. This gem requires a Hash#to_json method to be defined (hashes need to respond
|
71
|
+
to to_json and return valid JSON). If you for example have the json gem or the rails gem in your environment then this requirement is fulfilled.
|
72
|
+
|
73
|
+
The gem is tested on MRI 1.9.2.
|
74
|
+
|
75
|
+
## Credits
|
76
|
+
|
77
|
+
This gem is an extraction of production code at [Mag+](http://www.magplus.com) and both [Dennis Rogenius](https://github.com/denro) and [Lennart Friden](https://github.com/DevL) made important contributions along the way.
|
78
|
+
|
79
|
+
The APN connection code has its origins in the [APN on Rails](https://github.com/jwang/apn_on_rails) gem.
|
80
|
+
|
81
|
+
## License
|
82
|
+
|
83
|
+
This library is released under the MIT license.
|
84
|
+
|
85
|
+
## Resources
|
86
|
+
|
87
|
+
* [Apple Push Notifications Documentation](http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008194-CH1-SW1)
|
88
|
+
* [The APNS RubyGem](https://github.com/jpoz/APNS). Has a small codebase and a nice API. Does not use the enhanced format protocol and lacks error handling.
|
data/Rakefile
ADDED
data/apn_client.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "apn_client/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "apn_client"
|
7
|
+
s.version = ApnClient::VERSION
|
8
|
+
s.authors = ["Peter Marklund"]
|
9
|
+
s.email = ["peter@marklunds.com"]
|
10
|
+
s.homepage = ""
|
11
|
+
s.summary = %q{Library for sending Apple Push Notifications to iOS devices from Ruby}
|
12
|
+
s.description = %q{Uses the "enhanced format" Apple protocol and deals with errors and failures when broadcasting to many devices. Includes support for talking to the Apple Push Notification Feedback service for dealing with uninstalled apps.}
|
13
|
+
|
14
|
+
s.rubyforge_project = "apn_client"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
s.add_development_dependency "json"
|
22
|
+
s.add_development_dependency "rspec"
|
23
|
+
s.add_development_dependency "mocha"
|
24
|
+
s.add_development_dependency "yard"
|
25
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'openssl'
|
3
|
+
require 'apn_client/named_args'
|
4
|
+
|
5
|
+
module ApnClient
|
6
|
+
class Connection
|
7
|
+
attr_accessor :config, :tcp_socket, :ssl_socket
|
8
|
+
|
9
|
+
# Opens an SSL socket for talking to the Apple Push Notification service.
|
10
|
+
#
|
11
|
+
# @param [String] host the hostname to connect to
|
12
|
+
# @param [Fixnum] port the port to connect to
|
13
|
+
# @param [String] certificate the APN certificate to use
|
14
|
+
# @param [String] certificate_passphrase the passphrase of the certificate, can be empty
|
15
|
+
# @param [Float] select_timeout the timeout (seconds) used when doing IO.select on the socket (default 0.1)
|
16
|
+
def initialize(config = {})
|
17
|
+
NamedArgs.assert_valid!(config,
|
18
|
+
:required => [:host, :port, :certificate, :certificate_passphrase],
|
19
|
+
:optional => [:select_timeout])
|
20
|
+
self.config = config
|
21
|
+
config[:select_timeout] ||= 0.1
|
22
|
+
connect_to_socket
|
23
|
+
end
|
24
|
+
|
25
|
+
def close
|
26
|
+
ssl_socket.close
|
27
|
+
tcp_socket.close
|
28
|
+
self.ssl_socket = nil
|
29
|
+
self.tcp_socket = nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def write(arg)
|
33
|
+
ssl_socket.write(arg.to_s)
|
34
|
+
end
|
35
|
+
|
36
|
+
def read(*args)
|
37
|
+
ssl_socket.read(*args)
|
38
|
+
end
|
39
|
+
|
40
|
+
def select
|
41
|
+
IO.select([ssl_socket], nil, nil, config[:select_timeout])
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.open(options = {})
|
45
|
+
connection = Connection.new(options)
|
46
|
+
yield connection
|
47
|
+
ensure
|
48
|
+
connection.close if connection
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def connect_to_socket
|
54
|
+
context = OpenSSL::SSL::SSLContext.new
|
55
|
+
context.key = OpenSSL::PKey::RSA.new(config[:certificate], config[:certificate_passphrase])
|
56
|
+
context.cert = OpenSSL::X509::Certificate.new(config[:certificate])
|
57
|
+
|
58
|
+
self.tcp_socket = TCPSocket.new(config[:host], config[:port])
|
59
|
+
self.ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
|
60
|
+
ssl_socket.sync = true
|
61
|
+
ssl_socket.connect
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
require 'apn_client/named_args'
|
2
|
+
require 'apn_client/message'
|
3
|
+
require 'apn_client/connection'
|
4
|
+
|
5
|
+
module ApnClient
|
6
|
+
class Delivery
|
7
|
+
attr_accessor :messages, :callbacks, :consecutive_failure_limit, :exception_limit, :sleep_on_exception,
|
8
|
+
:exception_count, :success_count, :failure_count, :consecutive_failure_count,
|
9
|
+
:started_at, :finished_at
|
10
|
+
|
11
|
+
# Creates a new APN delivery
|
12
|
+
#
|
13
|
+
# @param [#next] messages should be Enumerator type object that responds to #next. If it's an Array #shift will be used instead.
|
14
|
+
def initialize(messages, options = {})
|
15
|
+
self.messages = messages
|
16
|
+
initialize_options(options)
|
17
|
+
self.exception_count = 0
|
18
|
+
self.success_count = 0
|
19
|
+
self.failure_count = 0
|
20
|
+
self.consecutive_failure_count = 0
|
21
|
+
end
|
22
|
+
|
23
|
+
def process!
|
24
|
+
self.started_at = Time.now
|
25
|
+
while current_message && consecutive_failure_count < consecutive_failure_limit
|
26
|
+
process_one_message!
|
27
|
+
end
|
28
|
+
close_connection
|
29
|
+
self.finished_at = Time.now
|
30
|
+
end
|
31
|
+
|
32
|
+
def elapsed
|
33
|
+
if started_at
|
34
|
+
finished_at ? (finished_at - started_at) : (Time.now - started_at)
|
35
|
+
else
|
36
|
+
0
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def total_count
|
41
|
+
success_count + failure_count
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def initialize_options(options)
|
47
|
+
NamedArgs.assert_valid!(options, :optional => [:callbacks, :consecutive_failure_limit, :exception_limit, :sleep_on_exception])
|
48
|
+
NamedArgs.assert_valid!(options[:callbacks], :optional => [:on_write, :on_error, :on_nil_select, :on_read_exception, :on_exception, :on_failure])
|
49
|
+
self.callbacks = options[:callbacks]
|
50
|
+
self.consecutive_failure_limit = options[:consecutive_failure_limit] || 10
|
51
|
+
self.exception_limit = options[:exception_limit] || 3
|
52
|
+
self.sleep_on_exception = options[:sleep_on_exception] || 1
|
53
|
+
end
|
54
|
+
|
55
|
+
def current_message
|
56
|
+
return @current_message if @current_message
|
57
|
+
next_message!
|
58
|
+
end
|
59
|
+
|
60
|
+
def next_message!
|
61
|
+
@current_message = (messages.respond_to?(:next) ? messages.next : messages.shift)
|
62
|
+
rescue StopIteration
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
|
66
|
+
def process_one_message!
|
67
|
+
begin
|
68
|
+
write_message!
|
69
|
+
check_message_error!
|
70
|
+
rescue Exception => e
|
71
|
+
handle_exception!(e)
|
72
|
+
check_message_error! unless @checked_message_error
|
73
|
+
close_connection
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def connection
|
78
|
+
@connection ||= Connection.new(self.class.connection_config)
|
79
|
+
end
|
80
|
+
|
81
|
+
def close_connection
|
82
|
+
@connection.close if @connection
|
83
|
+
@connection = nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def write_message!
|
87
|
+
@checked_message_error = false
|
88
|
+
connection.write(current_message)
|
89
|
+
self.exception_count = 0; self.consecutive_failure_count = 0; self.success_count += 1
|
90
|
+
invoke_callback(:on_write, current_message)
|
91
|
+
next_message!
|
92
|
+
end
|
93
|
+
|
94
|
+
def check_message_error!
|
95
|
+
@checked_message_error = true
|
96
|
+
failed_message_id, error_code = read_apns_error
|
97
|
+
# NOTE: According to the APN documentation the APN service will return an error code prior to
|
98
|
+
# disconnecting. If we don't disconnect here we will attempt to write more messages
|
99
|
+
# before a broken pipe error is raised and those messages will never be delivered.
|
100
|
+
if failed_message_id
|
101
|
+
invoke_callback(:on_error, failed_message_id, error_code)
|
102
|
+
self.failure_count += 1
|
103
|
+
self.success_count -= 1
|
104
|
+
close_connection
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def read_apns_error
|
109
|
+
message_id = error_code = nil
|
110
|
+
begin
|
111
|
+
select_return = nil
|
112
|
+
if connection && select_return = connection.select
|
113
|
+
response = connection.read(6)
|
114
|
+
command, error_code, message_id = response.unpack('cci') if response
|
115
|
+
else
|
116
|
+
invoke_callback(:on_nil_select)
|
117
|
+
end
|
118
|
+
rescue Exception => e
|
119
|
+
# NOTE: If we don't catch this exception then one socket read exception could break out of the whole delivery loop
|
120
|
+
invoke_callback(:on_read_exception, e)
|
121
|
+
end
|
122
|
+
return message_id, error_code
|
123
|
+
end
|
124
|
+
|
125
|
+
def handle_exception!(e)
|
126
|
+
invoke_callback(:on_exception, e)
|
127
|
+
self.exception_count += 1
|
128
|
+
fail_message! if exception_limit_reached?
|
129
|
+
sleep(sleep_on_exception) if sleep_on_exception
|
130
|
+
end
|
131
|
+
|
132
|
+
def exception_limit_reached?
|
133
|
+
exception_count == exception_limit
|
134
|
+
end
|
135
|
+
|
136
|
+
# # Give up on the message and move on to the next one
|
137
|
+
def fail_message!
|
138
|
+
self.failure_count += 1; self.consecutive_failure_count += 1; self.exception_count = 0
|
139
|
+
invoke_callback(:on_failure, current_message)
|
140
|
+
next_message!
|
141
|
+
end
|
142
|
+
|
143
|
+
def invoke_callback(name, *args)
|
144
|
+
callbacks[name].call(self, *args) if callbacks[name]
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'apn_client/named_args'
|
2
|
+
|
3
|
+
module ApnClient
|
4
|
+
class Message
|
5
|
+
attr_accessor :message_id, :device_token, :alert, :badge, :sound, :content_available, :custom_properties
|
6
|
+
|
7
|
+
# Creates an APN message to to be sent over SSL to the APN service.
|
8
|
+
#
|
9
|
+
# @param [Fixnum] message_id a unique (at least within a delivery) integer identifier for this message
|
10
|
+
# @param [String] device_token A 64 byte long hex digest supplied from an app installed on a device to the server
|
11
|
+
# @param [String] alert A text message to display to the user. Should be tweet sized (payload may not exceed 256 bytes)
|
12
|
+
# @param [Fixnum] badge the number to show on the badge on the app icon - number of new/unread items
|
13
|
+
# @param [String] sound filename of a sound file in the app bundle to be played to the user
|
14
|
+
# @param [Boolean] content_available set to true if the message should trigger download of new content
|
15
|
+
def initialize(message_id, config = {})
|
16
|
+
self.message_id = message_id
|
17
|
+
self.device_token = device_token
|
18
|
+
NamedArgs.assert_valid!(config,
|
19
|
+
:optional => [:alert, :badge, :sound, :content_available],
|
20
|
+
:required => [:device_token])
|
21
|
+
config.keys.each do |key|
|
22
|
+
send("#{key}=", config[key])
|
23
|
+
end
|
24
|
+
check_payload_size!
|
25
|
+
end
|
26
|
+
|
27
|
+
# We use the enhanced format. See the Apple documentation for details.
|
28
|
+
def to_s
|
29
|
+
[1, message_id, self.class.expires_at, 0, 32, device_token, 0, payload_size, payload].pack('ciiccH*cca*')
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.error_codes
|
33
|
+
{
|
34
|
+
:no_errors_encountered => 0,
|
35
|
+
:processing_error => 1,
|
36
|
+
:missing_device_token => 2,
|
37
|
+
:missing_topic => 3,
|
38
|
+
:missing_payload => 4,
|
39
|
+
:invalid_token_size => 5,
|
40
|
+
:invalid_topic_size => 6,
|
41
|
+
:invalid_payload_size => 7,
|
42
|
+
:invalid_token => 8,
|
43
|
+
:unknown => 255
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.expires_at
|
48
|
+
seconds_per_day = 24*3600
|
49
|
+
(Time.now + 30*seconds_per_day).to_i
|
50
|
+
end
|
51
|
+
|
52
|
+
# The payload is a JSON formated hash with alert, sound, badge, content-available,
|
53
|
+
# and any custom properties, example:
|
54
|
+
# {"aps" => {"badge" => 5, "sound" => "my_sound.aiff", "alert" => "Hello!"}}
|
55
|
+
def payload
|
56
|
+
payload_hash.to_json
|
57
|
+
end
|
58
|
+
|
59
|
+
def payload_hash
|
60
|
+
result = {}
|
61
|
+
result['aps'] = {}
|
62
|
+
result['aps']['alert'] = alert if alert
|
63
|
+
result['aps']['badge'] = badge if badge and badge > 0
|
64
|
+
if sound
|
65
|
+
result['aps']['sound'] = sound if sound.is_a? String
|
66
|
+
result['aps']['sound'] = "1.aiff" if sound.is_a? TrueClass
|
67
|
+
end
|
68
|
+
result['aps']['content-available'] = 1 if content_available
|
69
|
+
result
|
70
|
+
end
|
71
|
+
|
72
|
+
def payload_size
|
73
|
+
payload.bytesize
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def check_payload_size!
|
79
|
+
max_payload_size = 256
|
80
|
+
if payload_size > max_payload_size
|
81
|
+
raise "Payload is #{payload_size} bytes and it cannot exceed #{max_payload_size} bytes"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module ApnClient
|
2
|
+
class NamedArgs
|
3
|
+
def self.assert_valid!(arguments, options)
|
4
|
+
arguments ||= {}
|
5
|
+
options[:optional] ||= []
|
6
|
+
options[:required] ||= []
|
7
|
+
assert_allowed!(arguments, options[:optional] + options[:required])
|
8
|
+
assert_present!(arguments, options[:required])
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.assert_allowed!(arguments, allowed_keys)
|
12
|
+
invalid_keys = arguments.keys.select { |key| !allowed_keys.include?(key) }
|
13
|
+
unless invalid_keys.empty?
|
14
|
+
raise "Invalid arguments: #{invalid_keys.join(', ')}. Must be one of #{allowed_keys.join(', ')}"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.assert_present!(arguments, required_keys)
|
19
|
+
missing_keys = required_keys.select { |key| !arguments.keys.include?(key) }
|
20
|
+
unless missing_keys.empty?
|
21
|
+
raise "Missing required arguments: #{missing_keys.join(', ')}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/apn_client.rb
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'apn_client/connection'
|
4
|
+
|
5
|
+
describe ApnClient::Connection do
|
6
|
+
describe "#initialize" do
|
7
|
+
it "opens an SSL connection given host, port, certificate, and certificate_passphrase" do
|
8
|
+
if certificate_exists?
|
9
|
+
connection = ApnClient::Connection.new(valid_config)
|
10
|
+
connection.config.should == valid_config_with_defaults
|
11
|
+
connection.tcp_socket.is_a?(TCPSocket).should be_true
|
12
|
+
connection.ssl_socket.is_a?(OpenSSL::SSL::SSLSocket).should be_true
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it "raises an exception if a required argument is missing" do
|
17
|
+
if certificate_exists?
|
18
|
+
TCPSocket.expects(:new).never
|
19
|
+
lambda {
|
20
|
+
connection = ApnClient::Connection.new(valid_config.reject { |key| key == :host })
|
21
|
+
}.should raise_error(/host/)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
it "can take a select_timeout argument" do
|
26
|
+
if certificate_exists?
|
27
|
+
config = valid_config.merge(:select_timeout => 0.5)
|
28
|
+
connection = ApnClient::Connection.new(config)
|
29
|
+
connection.config.should == config
|
30
|
+
connection.tcp_socket.is_a?(TCPSocket).should be_true
|
31
|
+
connection.ssl_socket.is_a?(OpenSSL::SSL::SSLSocket).should be_true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
it "does not accept invalid arguments" do
|
36
|
+
invalid_config = valid_config.merge({:foobar => 1})
|
37
|
+
TCPSocket.expects(:new).never
|
38
|
+
lambda {
|
39
|
+
ApnClient::Connection.new(invalid_config)
|
40
|
+
}.should raise_error(/foobar/)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe "#close" do
|
45
|
+
it "closes ssl and tcp sockets and sets them to nil" do
|
46
|
+
if certificate_exists?
|
47
|
+
connection = ApnClient::Connection.new(valid_config)
|
48
|
+
connection.close
|
49
|
+
connection.tcp_socket.should be_nil
|
50
|
+
connection.ssl_socket.should be_nil
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "#write" do
|
56
|
+
it "invokes write on the ssl socket" do
|
57
|
+
ApnClient::Connection.any_instance.expects(:connect_to_socket)
|
58
|
+
connection = ApnClient::Connection.new(valid_config)
|
59
|
+
ssl_socket = mock('ssl_socket')
|
60
|
+
ssl_socket.expects(:write).with('foo')
|
61
|
+
connection.expects(:ssl_socket).returns(ssl_socket)
|
62
|
+
connection.write(:foo)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
describe "#read" do
|
67
|
+
it "invokes read on the ssl socket" do
|
68
|
+
ApnClient::Connection.any_instance.expects(:connect_to_socket)
|
69
|
+
connection = ApnClient::Connection.new(valid_config)
|
70
|
+
ssl_socket = mock('ssl_socket')
|
71
|
+
ssl_socket.expects(:read).with("foo", "bar")
|
72
|
+
connection.expects(:ssl_socket).returns(ssl_socket)
|
73
|
+
connection.read("foo", "bar")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe "#select" do
|
78
|
+
it "does an IO.select on the ssl socket with a timeout" do
|
79
|
+
ApnClient::Connection.any_instance.expects(:connect_to_socket)
|
80
|
+
connection = ApnClient::Connection.new(valid_config.merge(:select_timeout => 0.9))
|
81
|
+
ssl_socket = mock('ssl_socket')
|
82
|
+
connection.expects(:ssl_socket).returns(ssl_socket)
|
83
|
+
IO.expects(:select).with([ssl_socket], nil, nil, 0.9)
|
84
|
+
connection.select
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe ".open" do
|
89
|
+
it "opens a connection, yields it to a block, then closes it" do
|
90
|
+
ApnClient::Connection.any_instance.expects(:connect_to_socket)
|
91
|
+
ApnClient::Connection.any_instance.expects(:close)
|
92
|
+
ApnClient::Connection.open(valid_config) do |connection|
|
93
|
+
connection.config.should == valid_config_with_defaults
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def certificate_path
|
99
|
+
File.join(File.dirname(__FILE__), "certificate.pem")
|
100
|
+
end
|
101
|
+
|
102
|
+
def certificate_exists?
|
103
|
+
File.exists?(certificate_path)
|
104
|
+
end
|
105
|
+
|
106
|
+
def valid_config
|
107
|
+
{
|
108
|
+
:host => 'gateway.push.apple.com',
|
109
|
+
:port => 2195,
|
110
|
+
:certificate => IO.read(certificate_path),
|
111
|
+
:certificate_passphrase => ''
|
112
|
+
}
|
113
|
+
end
|
114
|
+
|
115
|
+
def valid_config_with_defaults
|
116
|
+
valid_config.merge(:select_timeout => 0.1)
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'apn_client/delivery'
|
4
|
+
|
5
|
+
describe ApnClient::Delivery do
|
6
|
+
before(:each) do
|
7
|
+
@message1 = ApnClient::Message.new(1,
|
8
|
+
:device_token => "7b7b8de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d099",
|
9
|
+
:alert => "New version of the app is out. Get it now in the app store!",
|
10
|
+
:badge => 2
|
11
|
+
)
|
12
|
+
@message2 = ApnClient::Message.new(2,
|
13
|
+
:device_token => "6a5g4de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d044",
|
14
|
+
:alert => "New version of the app is out. Get it now in the app store!",
|
15
|
+
:badge => 1
|
16
|
+
)
|
17
|
+
end
|
18
|
+
|
19
|
+
describe "#initialize" do
|
20
|
+
it "initializes counts and other attributes" do
|
21
|
+
delivery = create_delivery([@message1, @message2])
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe "#process!" do
|
26
|
+
it "can deliver to all messages successfully and invoke on_write callback" do
|
27
|
+
messages = [@message1, @message2]
|
28
|
+
written_messages = []
|
29
|
+
nil_selects = 0
|
30
|
+
callbacks = {
|
31
|
+
:on_write => lambda { |d, m| written_messages << m },
|
32
|
+
:on_nil_select => lambda { |d| nil_selects += 1 }
|
33
|
+
}
|
34
|
+
delivery = create_delivery(messages.dup, :callbacks => callbacks)
|
35
|
+
|
36
|
+
connection = mock('connection')
|
37
|
+
connection.expects(:write).with(@message1)
|
38
|
+
connection.expects(:write).with(@message2)
|
39
|
+
connection.expects(:select).times(2).returns(nil)
|
40
|
+
delivery.stubs(:connection).returns(connection)
|
41
|
+
|
42
|
+
delivery.process!
|
43
|
+
|
44
|
+
delivery.failure_count.should == 0
|
45
|
+
delivery.success_count.should == 2
|
46
|
+
delivery.total_count.should == 2
|
47
|
+
written_messages.should == messages
|
48
|
+
nil_selects.should == 2
|
49
|
+
end
|
50
|
+
|
51
|
+
it "fails a message if it fails more than 3 times" do
|
52
|
+
messages = [@message1, @message2]
|
53
|
+
written_messages = []
|
54
|
+
exceptions = []
|
55
|
+
failures = []
|
56
|
+
read_exceptions = []
|
57
|
+
callbacks = {
|
58
|
+
:on_write => lambda { |d, m| written_messages << m },
|
59
|
+
:on_exception => lambda { |d, e| exceptions << e },
|
60
|
+
:on_failure => lambda { |d, m| failures << m },
|
61
|
+
:on_read_exception => lambda { |d, e| read_exceptions << e }
|
62
|
+
}
|
63
|
+
delivery = create_delivery(messages.dup, :callbacks => callbacks)
|
64
|
+
|
65
|
+
connection = mock('connection')
|
66
|
+
connection.expects(:write).with(@message1).times(3).raises(RuntimeError)
|
67
|
+
connection.expects(:write).with(@message2)
|
68
|
+
connection.expects(:select).times(4).raises(RuntimeError)
|
69
|
+
delivery.stubs(:connection).returns(connection)
|
70
|
+
|
71
|
+
delivery.process!
|
72
|
+
|
73
|
+
delivery.failure_count.should == 1
|
74
|
+
delivery.success_count.should == 1
|
75
|
+
delivery.total_count.should == 2
|
76
|
+
written_messages.should == [@message2]
|
77
|
+
exceptions.size.should == 3
|
78
|
+
exceptions.first.is_a?(RuntimeError).should be_true
|
79
|
+
failures.should == [@message1]
|
80
|
+
read_exceptions.size.should == 4
|
81
|
+
end
|
82
|
+
|
83
|
+
it "invokes on_error callback if there are errors read" do
|
84
|
+
messages = [@message1, @message2]
|
85
|
+
written_messages = []
|
86
|
+
exceptions = []
|
87
|
+
failures = []
|
88
|
+
read_exceptions = []
|
89
|
+
errors = []
|
90
|
+
callbacks = {
|
91
|
+
:on_write => lambda { |d, m| written_messages << m },
|
92
|
+
:on_exception => lambda { |d, e| exceptions << e },
|
93
|
+
:on_failure => lambda { |d, m| failures << m },
|
94
|
+
:on_read_exception => lambda { |d, e| read_exceptions << e },
|
95
|
+
:on_error => lambda { |d, message_id, error_code| errors << [message_id, error_code] }
|
96
|
+
}
|
97
|
+
delivery = create_delivery(messages.dup, :callbacks => callbacks)
|
98
|
+
|
99
|
+
connection = mock('connection')
|
100
|
+
connection.expects(:write).with(@message1)
|
101
|
+
connection.expects(:write).with(@message2)
|
102
|
+
selects = sequence('selects')
|
103
|
+
connection.expects(:select).returns("something").in_sequence(selects)
|
104
|
+
connection.expects(:select).returns(nil).in_sequence(selects)
|
105
|
+
connection.expects(:read).returns("something")
|
106
|
+
delivery.stubs(:connection).returns(connection)
|
107
|
+
|
108
|
+
delivery.process!
|
109
|
+
|
110
|
+
delivery.failure_count.should == 1
|
111
|
+
delivery.success_count.should == 1
|
112
|
+
delivery.total_count.should == 2
|
113
|
+
written_messages.should == [@message1, @message2]
|
114
|
+
exceptions.size.should == 0
|
115
|
+
failures.size.should == 0
|
116
|
+
errors.should == [[1752458605, 111]]
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def create_delivery(messages, options = {})
|
121
|
+
delivery = ApnClient::Delivery.new(messages, options)
|
122
|
+
delivery.messages.should == messages
|
123
|
+
delivery.callbacks.should == options[:callbacks]
|
124
|
+
delivery.exception_count.should == 0
|
125
|
+
delivery.success_count.should == 0
|
126
|
+
delivery.failure_count.should == 0
|
127
|
+
delivery.consecutive_failure_count.should == 0
|
128
|
+
delivery.started_at.should be_nil
|
129
|
+
delivery.finished_at.should be_nil
|
130
|
+
delivery.elapsed.should == 0
|
131
|
+
delivery.consecutive_failure_limit.should == 10
|
132
|
+
delivery.exception_limit.should == 3
|
133
|
+
delivery.sleep_on_exception.should == 1
|
134
|
+
delivery
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'apn_client/message'
|
4
|
+
|
5
|
+
describe ApnClient::Message do
|
6
|
+
before(:each) do
|
7
|
+
@device_token = "7b7b8de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d099"
|
8
|
+
@alert = "Hello, check out version 9.5 of our awesome app in the app store"
|
9
|
+
@badge = 3
|
10
|
+
end
|
11
|
+
|
12
|
+
describe "#initialize" do
|
13
|
+
it "cannot be created without a token" do
|
14
|
+
lambda {
|
15
|
+
ApnClient::Message.new(1)
|
16
|
+
}.should raise_error(/device_token/)
|
17
|
+
end
|
18
|
+
|
19
|
+
it "can be created with a token and an alert" do
|
20
|
+
message = create_message(1, :device_token => @device_token, :alert => @alert)
|
21
|
+
message.payload_hash.should == {'aps' => {'alert' => @alert}}
|
22
|
+
end
|
23
|
+
|
24
|
+
it "can be created with a token and an alert and a badge" do
|
25
|
+
message = create_message(1, :device_token => @device_token, :alert => @alert, :badge => @badge)
|
26
|
+
message.payload_hash.should == {'aps' => {'alert' => @alert, 'badge' => @badge}}
|
27
|
+
end
|
28
|
+
|
29
|
+
it "can be created with a token and an alert and a badge and content-available" do
|
30
|
+
message = create_message(1,
|
31
|
+
:device_token => @device_token,
|
32
|
+
:alert => @alert,
|
33
|
+
:badge => @badge,
|
34
|
+
:content_available => true)
|
35
|
+
message.payload_hash.should == {'aps' => {'alert' => @alert, 'badge' => @badge, 'content-available' => 1}}
|
36
|
+
end
|
37
|
+
|
38
|
+
it "raises an exception if payload_size exceeds 256 bytes" do
|
39
|
+
lambda {
|
40
|
+
too_long_alert = "A"*1000
|
41
|
+
ApnClient::Message.new(1, :device_token => @device_token, :alert => too_long_alert)
|
42
|
+
}.should raise_error(/payload/i)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe "#payload_size" do
|
47
|
+
it "returns number of bytes in the payload"
|
48
|
+
end
|
49
|
+
|
50
|
+
def create_message(message_id, config = {})
|
51
|
+
message = ApnClient::Message.new(message_id, config)
|
52
|
+
message.message_id.should == 1
|
53
|
+
[:device_token, :alert, :badge, :sound, :content_available].each do |attribute|
|
54
|
+
message.send(attribute).should == config[attribute]
|
55
|
+
end
|
56
|
+
message.payload_size.should < 256
|
57
|
+
message.to_s.should_not be_nil
|
58
|
+
message
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'apn_client/named_args'
|
4
|
+
|
5
|
+
describe ApnClient::NamedArgs do
|
6
|
+
describe ".assert_allowed!" do
|
7
|
+
it "raises an exception if the argument hash contains a key not in the allowed list" do
|
8
|
+
lambda {
|
9
|
+
ApnClient::NamedArgs.assert_allowed!({:foo => 1, :bla => 2}, [:foo, :bar])
|
10
|
+
}.should raise_error(/foo/)
|
11
|
+
end
|
12
|
+
|
13
|
+
it "does not raise an exception if the arguments hash is empty" do
|
14
|
+
ApnClient::NamedArgs.assert_allowed!({}, [:foo, :bar])
|
15
|
+
end
|
16
|
+
|
17
|
+
it "does not raise an exception if all allowed keys are provided" do
|
18
|
+
ApnClient::NamedArgs.assert_allowed!({:foo => 1, :bar => 2}, [:foo, :bar])
|
19
|
+
end
|
20
|
+
|
21
|
+
it "does not raise an exception if a subset of allowed keys are provided" do
|
22
|
+
ApnClient::NamedArgs.assert_allowed!({:foo => 1}, [:foo, :bar])
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
describe ".assert_present!" do
|
27
|
+
it "raises an exception if arguments are empty and required keys are not empty" do
|
28
|
+
lambda {
|
29
|
+
ApnClient::NamedArgs.assert_present!({}, [:foo])
|
30
|
+
}.should raise_error(/foo/)
|
31
|
+
end
|
32
|
+
|
33
|
+
it "does not raise an exception if arguments are empty and required keys are empty" do
|
34
|
+
ApnClient::NamedArgs.assert_present!({}, [])
|
35
|
+
end
|
36
|
+
|
37
|
+
it "raises an exception if arguments have some but not all required keys" do
|
38
|
+
lambda {
|
39
|
+
ApnClient::NamedArgs.assert_present!({:bar => 1}, [:foo, :bar])
|
40
|
+
}.should raise_error(/foo/)
|
41
|
+
end
|
42
|
+
|
43
|
+
it "does not raise an excpeption if arguments have all required keys" do
|
44
|
+
ApnClient::NamedArgs.assert_present!({:foo => 1, :bar => 2}, [:foo, :bar])
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe ".assert_valid!" do
|
49
|
+
it "can take only optional keys" do
|
50
|
+
arguments = {:foo => 1}
|
51
|
+
ApnClient::NamedArgs.expects(:assert_allowed!).with(arguments, [:foo])
|
52
|
+
ApnClient::NamedArgs.expects(:assert_present!).with(arguments, [])
|
53
|
+
ApnClient::NamedArgs.assert_valid!(arguments, :optional => [:foo])
|
54
|
+
end
|
55
|
+
|
56
|
+
it "can take only required keys" do
|
57
|
+
arguments = {:foo => 1}
|
58
|
+
ApnClient::NamedArgs.expects(:assert_allowed!).with(arguments, [:foo])
|
59
|
+
ApnClient::NamedArgs.expects(:assert_present!).with(arguments, [:foo])
|
60
|
+
ApnClient::NamedArgs.assert_valid!(arguments, :required => [:foo])
|
61
|
+
end
|
62
|
+
|
63
|
+
it "can take both optional and required keys" do
|
64
|
+
arguments = {:foo => 1}
|
65
|
+
ApnClient::NamedArgs.expects(:assert_allowed!).with(arguments, [:bar, :foo])
|
66
|
+
ApnClient::NamedArgs.expects(:assert_present!).with(arguments, [:foo])
|
67
|
+
ApnClient::NamedArgs.assert_valid!(arguments, :required => [:foo], :optional => [:bar])
|
68
|
+
end
|
69
|
+
|
70
|
+
it "does not raise exception if only required args are provided" do
|
71
|
+
ApnClient::NamedArgs.assert_valid!({:foo => 1}, :required => [:foo], :optional => [:bar])
|
72
|
+
end
|
73
|
+
|
74
|
+
it "does not raise exception if required and optional args are provided" do
|
75
|
+
ApnClient::NamedArgs.assert_valid!({:foo => 1, :bar => 2}, :required => [:foo], :optional => [:bar])
|
76
|
+
end
|
77
|
+
|
78
|
+
it "raises an exception if a required arg is missing" do
|
79
|
+
lambda {
|
80
|
+
ApnClient::NamedArgs.assert_valid!({:bar => 2}, :required => [:foo], :optional => [:bar])
|
81
|
+
}.should raise_error(/foo/)
|
82
|
+
end
|
83
|
+
|
84
|
+
it "raises an exception if an invalid arg is present" do
|
85
|
+
lambda {
|
86
|
+
ApnClient::NamedArgs.assert_valid!({:foo => 1, :bar => 2, :bla => 3}, :required => [:foo], :optional => [:bar])
|
87
|
+
}.should raise_error(/bla/)
|
88
|
+
end
|
89
|
+
|
90
|
+
it "does not raise an exception if args are nil and all keys are optional" do
|
91
|
+
ApnClient::NamedArgs.assert_valid!(nil, :optional => [:bar])
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: apn_client
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Peter Marklund
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-12-02 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: json
|
16
|
+
requirement: &70199261615240 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70199261615240
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: rspec
|
27
|
+
requirement: &70199261614820 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70199261614820
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: mocha
|
38
|
+
requirement: &70199261614400 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ! '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *70199261614400
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: yard
|
49
|
+
requirement: &70199261613980 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :development
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *70199261613980
|
58
|
+
description: Uses the "enhanced format" Apple protocol and deals with errors and failures
|
59
|
+
when broadcasting to many devices. Includes support for talking to the Apple Push
|
60
|
+
Notification Feedback service for dealing with uninstalled apps.
|
61
|
+
email:
|
62
|
+
- peter@marklunds.com
|
63
|
+
executables: []
|
64
|
+
extensions: []
|
65
|
+
extra_rdoc_files: []
|
66
|
+
files:
|
67
|
+
- .gitignore
|
68
|
+
- .rspec
|
69
|
+
- .rvmrc
|
70
|
+
- Gemfile
|
71
|
+
- README.md
|
72
|
+
- Rakefile
|
73
|
+
- apn_client.gemspec
|
74
|
+
- lib/apn_client.rb
|
75
|
+
- lib/apn_client/connection.rb
|
76
|
+
- lib/apn_client/delivery.rb
|
77
|
+
- lib/apn_client/message.rb
|
78
|
+
- lib/apn_client/named_args.rb
|
79
|
+
- lib/apn_client/version.rb
|
80
|
+
- spec/connection_spec.rb
|
81
|
+
- spec/delivery_spec.rb
|
82
|
+
- spec/message_spec.rb
|
83
|
+
- spec/named_args_spec.rb
|
84
|
+
- spec/spec_helper.rb
|
85
|
+
homepage: ''
|
86
|
+
licenses: []
|
87
|
+
post_install_message:
|
88
|
+
rdoc_options: []
|
89
|
+
require_paths:
|
90
|
+
- lib
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
92
|
+
none: false
|
93
|
+
requirements:
|
94
|
+
- - ! '>='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
none: false
|
99
|
+
requirements:
|
100
|
+
- - ! '>='
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
requirements: []
|
104
|
+
rubyforge_project: apn_client
|
105
|
+
rubygems_version: 1.8.10
|
106
|
+
signing_key:
|
107
|
+
specification_version: 3
|
108
|
+
summary: Library for sending Apple Push Notifications to iOS devices from Ruby
|
109
|
+
test_files:
|
110
|
+
- spec/connection_spec.rb
|
111
|
+
- spec/delivery_spec.rb
|
112
|
+
- spec/message_spec.rb
|
113
|
+
- spec/named_args_spec.rb
|
114
|
+
- spec/spec_helper.rb
|
115
|
+
has_rdoc:
|