gs-apns 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ .DS_Store
2
+ pkg
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in APNS.gemspec
4
+ gemspec
data/MIT-LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2009 James Pozdena, 2010 Justin.tv, 2013 Geospike Pty Ltd
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
data/README.textile ADDED
@@ -0,0 +1,158 @@
1
+ h1. gs-apns
2
+
3
+ gs-apns is a gem for accessing the Apple Push Notification Service that allows
4
+ both sending notifications and reading from apple's feedback service. This gem
5
+ is based heavily on the work of James Pozdena (http://github.com/jpoz/APNS) and
6
+ "Paul Gebheim" (http://github.com/jpoz/APNS).
7
+
8
+ Updates by William Denniss of Geospike: Apple's APNS service does not support JSON that isn't ascii encoded. Prior to rails 3.2.13
9
+ it was encoded, but now it isn't.
10
+
11
+
12
+ h2. Install
13
+
14
+ <pre>
15
+ sudo gem install jtv-apns
16
+ </pre>
17
+
18
+ h2. Setup:
19
+
20
+ First, you will need to export your development/production iphone push service
21
+ certificate and key from your keychain. To do this select the private key and
22
+ certificate, and select File -> Export from your keychain. By default a .p12
23
+ file will be generated containing certificate and key.
24
+
25
+ Next, run the following command to convert this .p12 file into a .pem file.
26
+ <pre>
27
+ openssl pkcs12 -in cert.p12 -out cert.pem -nodes -clcerts
28
+ </pre>
29
+
30
+ This pem file should be stored somewhere secure that your application can access. Next, set the jtv-apns configuration parameters:
31
+
32
+ <pre>
33
+ ###################
34
+ # Hosts Config
35
+ ###################
36
+
37
+ # Push Notification Service:
38
+ #
39
+ # (default: gateway.sandbox.push.apple.com is)
40
+ # Set as below for a production install
41
+ APNS.host = 'gateway.push.apple.com'
42
+
43
+ # (default: 2195)
44
+ # APNS.port = 2195
45
+
46
+ # Feedback Service:
47
+ #
48
+ # (default: feedback.sandbox.push.apple.com)
49
+ APNS.feedback_host = 'feedback.push.apple.com'
50
+
51
+ # (default: 2196)
52
+ # APNS.feedback_port = 2196
53
+
54
+ ####################
55
+ # Certificate Setup
56
+ ####################
57
+
58
+ # Path to the .pem file created earlier
59
+ APNS.pem = '/path/to/pem/file'
60
+
61
+ # Password for decrypting the .pem file, if one was used
62
+ APNS.pass = 'xxxx'
63
+
64
+ ####################
65
+ # Connection Mgmt
66
+ ####################
67
+
68
+ # Cache open connections when sending push notifications
69
+ # this will force the gem to keep 1 connection open per
70
+ # host/port pair, and reuse it when sending notifications
71
+
72
+ # (default: false)
73
+ # APNS.cache_connections = true
74
+ </pre>
75
+
76
+ h2. Example (Single notification):
77
+
78
+ Then to send a push notification you can either just send a string as the alert or give it a hash for the alert, badge and sound.
79
+
80
+ <pre>
81
+ device_token = '123abc456def'
82
+
83
+ APNS.send_notification(device_token, 'Hello iPhone!')
84
+ APNS.send_notification(device_token, :aps => {:alert => 'Hello iPhone!', :badge => 1, :sound => 'default'})
85
+ </pre>
86
+
87
+ h2. Example (Multiple notifications):
88
+
89
+ You can also send multiple notifications using the same connection to Apple:
90
+
91
+ <pre>
92
+ device_token = '123abc456def'
93
+
94
+ n1 = [device_token, :aps => { :alert => 'Hello...', :badge => 1, :sound => 'default' }
95
+ n2 = [device_token, :aps => { :alert => '... iPhone!', :badge => 1, :sound => 'default' }]
96
+
97
+ APNS.send_notifications([n1, n2])
98
+ </pre>
99
+
100
+
101
+ h2. Send other info along with aps
102
+
103
+ Application-specific information can be included along with the alert by
104
+ passing it along with the "aps" key in the message hash.
105
+
106
+ <pre>
107
+ APNS.send_notification(device_token, :aps => { :alert => 'Hello iPhone!', :badge => 1, :sound => 'default'},
108
+ :sent_by => 'Justin.tv')
109
+ </pre>
110
+
111
+ h2. Pre-establishing connections
112
+
113
+ If connection caching is enabled, you can tell the gem to establish connections before sending any notifications.
114
+
115
+ <pre>
116
+ APNS.establish_notification_connection
117
+ # ...
118
+ if APNS.has_notification_connection?
119
+ APNS.send_notification(device_token, "It works!")
120
+ end
121
+ </pre>
122
+
123
+ h2. Accessing the feedback service
124
+
125
+ jtv-apns provides a simple api to access Apple's feedback service. Below is an example for setting the feedback time on an ActiveRecord object corresponding to a device token.
126
+
127
+ <pre>
128
+ # APNS.feedback_each returns an array of Hash objects with the following keys
129
+ # :feedback_on => (Time) Time Apple considers app unregistered from device
130
+ # :length => (Fixnum) Length of :device_token, currently always 32 (bytes)
131
+ # :device_token => (String) hex-encoded device token
132
+ APNS.feedback.each do |feedback|
133
+ d = RegisteredDevices.find(:first, :conditions => { :device_token = feedback.device_token })
134
+ unless d.nil?
135
+ d.feedback_on = feedback.feedback_on
136
+ end
137
+ end
138
+ </pre>
139
+
140
+ h2. Getting your iPhone's device token
141
+
142
+ After you setup push notification for your application with Apple. You need to ask Apple for you application specific device token.
143
+
144
+ ApplicationAppDelegate.m
145
+ <pre>
146
+ - (void)applicationDidFinishLaunching:(UIApplication *)application {
147
+ // Register with apple that this app will use push notification
148
+ [[UIApplication sharedApplication] registerForRemoteNotificationTypes:(UIRemoteNotificationTypeAlert |
149
+ UIRemoteNotificationTypeSound | UIRemoteNotificationTypeBadge)];
150
+ }
151
+
152
+ - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
153
+ // Show the device token obtained from apple to the log
154
+ NSLog(@"deviceToken: %@", deviceToken);
155
+ }
156
+ </pre>
157
+
158
+
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
data/apns.gemspec ADDED
@@ -0,0 +1,20 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/apns/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["William Denniss", "Paul Gebheim", "James Pozdena"]
6
+ gem.email = ["will@geospike.com"]
7
+ gem.description = %q{Simple Apple push notification service gem}
8
+ gem.summary = %q{Simple Apple push notification service gem}
9
+ gem.homepage = %q{http://github.com/WilliamDenniss/APNS}
10
+
11
+ gem.extra_rdoc_files = ["MIT-LICENSE"]
12
+ gem.files = `git ls-files`.split($\)
13
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
14
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
15
+ gem.name = "gs-apns"
16
+ gem.require_paths = ["lib"]
17
+ gem.version = APNS::VERSION
18
+
19
+ gem.add_development_dependency "rspec", "~> 2.6"
20
+ end
data/lib/apns.rb ADDED
@@ -0,0 +1,27 @@
1
+ # Copyright (c) 2009 James Pozdena, 2010 Justin.tv
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ require 'apns/exceptions'
25
+ require 'apns/apns_json'
26
+ require 'apns/truncate'
27
+ require 'apns/core'
@@ -0,0 +1,47 @@
1
+ # Copyright (c) 2013 William Denniss
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ module APNS
25
+ require 'json'
26
+
27
+ class ApnsJSON
28
+
29
+ # generates JSON in a format acceptable to the APNS service (which is a subset of the JSON standard)
30
+ def self.apns_json(object)
31
+
32
+ JSON.generate(object, :ascii_only => false)
33
+ end
34
+
35
+ # calculates the byte-length of an object when encoded with APNS friendly JSON encoding
36
+ # if a string is passed, the byte-size is calculated as if it were in an object structure
37
+ def self.apns_json_size(object)
38
+
39
+ if object.is_a?(Hash) || object.is_a?(Array)
40
+ return apns_json(object).bytesize
41
+ else object.is_a?(String)
42
+ # wraps string in an array but discounts the extra chars
43
+ return apns_json([object]).bytesize - 4
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/apns/core.rb ADDED
@@ -0,0 +1,254 @@
1
+ # Copyright (c) 2009 James Pozdena, 2010 Justin.tv, 2013 William Denniss
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ module APNS
25
+ require 'socket'
26
+ require 'openssl'
27
+
28
+ # Host for push notification service
29
+ # production: gateway.push.apple.com
30
+ # development: gateway.sandbox.apple.com
31
+ @host = 'gateway.sandbox.push.apple.com'
32
+ @port = 2195
33
+
34
+ # Host for feedback service
35
+ # production: feedback.push.apple.com
36
+ # development: feedback.sandbox.apple.com
37
+ @feedback_host = 'feedback.sandbox.push.apple.com'
38
+ @feedback_port = 2196
39
+
40
+ # openssl pkcs12 -in mycert.p12 -out client-cert.pem -nodes -clcerts
41
+ @pem = nil # this should be the path of the pem file not the contentes
42
+ @pass = nil
43
+
44
+ @cache_connections = false
45
+ @connections = {}
46
+
47
+ @truncate_mode = Truncate::TRUNCATE_METHOD_SOFT
48
+ @truncate_max_chopped = 10
49
+ @clean_whitespace = true
50
+
51
+ @logging = true
52
+
53
+ class << self
54
+ attr_accessor :host, :port, :feedback_host, :feedback_port, :pem, :pass, :cache_connections, :clean_whitespace, :truncate_mode, :truncate_max_chopped, :logging
55
+ end
56
+
57
+ def self.establish_notification_connection
58
+ if @cache_connections
59
+ begin
60
+ self.get_connection(self.host, self.port)
61
+ return true
62
+ rescue
63
+ end
64
+ end
65
+ return false
66
+ end
67
+
68
+ def self.has_notification_connection?
69
+ return self.has_connection?(self.host, self.port)
70
+ end
71
+
72
+ def self.send_notification(device_token, message, notification_id = rand(9999), expiry = (Time.now + 1.year))
73
+ self.with_notification_connection do |conn|
74
+ conn.write(self.packaged_notification(device_token, message, notification_id, expiry))
75
+ conn.flush
76
+ end
77
+ end
78
+
79
+ def self.send_notifications(notifications)
80
+ self.with_notification_connection do |conn|
81
+ notifications.each do |n|
82
+ conn.write(self.packaged_notification(n[0], n[1], (n[2] or rand(9999)), (n[3] or (Time.now + 1.year))))
83
+ end
84
+ conn.flush
85
+ end
86
+ end
87
+
88
+ def self.feedback
89
+ apns_feedback = []
90
+ self.with_feedback_connection do |conn|
91
+ # Read buffers data from the OS, so it's probably not
92
+ # too inefficient to do the small reads
93
+ while data = conn.read(38)
94
+ apns_feedback << self.parse_feedback_tuple(data)
95
+ end
96
+ end
97
+
98
+ return apns_feedback
99
+ end
100
+
101
+ protected
102
+
103
+ # Each tuple is in the following format:
104
+ #
105
+ # timestamp | token_length (32) | token
106
+ # bytes: 4 (big-endian) 2 (big-endian) | 32
107
+ #
108
+ # timestamp - seconds since the epoch, in UTC
109
+ # token_length - Always 32 for now
110
+ # token - 32 bytes of binary data specifying the device token
111
+ #
112
+ def self.parse_feedback_tuple(data)
113
+ feedback = data.unpack('N1n1H64')
114
+ {:feedback_at => Time.at(feedback[0]), :length => feedback[1], :device_token => feedback[2] }
115
+ end
116
+
117
+ def self.packaged_notification(device_token, message, identifier, expiry)
118
+ pt = self.packaged_token(device_token)
119
+ pm = self.packaged_message(message)
120
+ raise APNSException, "payload exceeds 256 byte limit" if pm.bytesize > 256
121
+
122
+ #return [0, 32, pt, pm.bytesize, pm].pack("cna*na*") # old format (NB. s> notation only compatible with ruby 1.9.3 and above)
123
+
124
+ expiry_unix = expiry.to_i
125
+ expiry_unix = 0 if expiry_unix < 0 # for APNS a zero timestamp has the same effect as a negative one and we are only encoding signed ints
126
+ puts "[APNS] packaging notification for device:[#{device_token}] identifier:#{identifier} expiry:#{expiry_unix} payload-size:(#{pm.bytesize}) payload:#{pm}" if @logging
127
+ [1, identifier.to_i, expiry_unix, 32, pt, pm.bytesize, pm].pack("cNNna*na*")
128
+ end
129
+
130
+ def self.packaged_token(device_token)
131
+ [device_token.gsub(/[\s|<|>]/,'')].pack('H*')
132
+ end
133
+
134
+ def self.packaged_message(message)
135
+ if message.is_a?(Hash)
136
+ hash = message
137
+ elsif message.is_a?(String)
138
+ hash = {:aps => {:alert => message } }
139
+ else
140
+ raise "Message needs to be either a hash or string"
141
+ end
142
+ hash = Truncate.truncate_notification(hash, @clean_whitespace, @truncate_mode, @truncate_max_chopped)
143
+ ApnsJSON.apns_json(hash)
144
+ end
145
+
146
+ def self.with_notification_connection(&block)
147
+ self.with_connection(self.host, self.port, &block)
148
+ end
149
+
150
+ def self.with_feedback_connection(&block)
151
+ # Explicitly disable the connection cache for feedback
152
+ cache_temp = @cache_connections
153
+ @cache_connections = false
154
+
155
+ self.with_connection(self.feedback_host, self.feedback_port, &block)
156
+
157
+ ensure
158
+ @cache_connections = cache_temp
159
+ end
160
+
161
+ private
162
+
163
+ def self.open_connection(host, port)
164
+ raise "The path to your pem file is not set. (APNS.pem = /path/to/cert.pem)" unless self.pem
165
+ raise "The path to your pem file does not exist!" unless File.exist?(self.pem)
166
+
167
+ context = OpenSSL::SSL::SSLContext.new
168
+ context.cert = OpenSSL::X509::Certificate.new(File.read(self.pem))
169
+ context.key = OpenSSL::PKey::RSA.new(File.read(self.pem), self.pass)
170
+
171
+ retries = 0
172
+ begin
173
+ sock = TCPSocket.new(host, port)
174
+ ssl = OpenSSL::SSL::SSLSocket.new(sock, context)
175
+ ssl.connect
176
+ return ssl, sock
177
+ rescue SystemCallError
178
+ if (retries += 1) < 5
179
+ sleep 1
180
+ retry
181
+ else
182
+ # Too many retries, re-raise this exception
183
+ raise
184
+ end
185
+ end
186
+ end
187
+
188
+ def self.has_connection?(host, port)
189
+ @connections.has_key?([host,port])
190
+ end
191
+
192
+ def self.create_connection(host, port)
193
+ @connections[[host, port]] = self.open_connection(host, port)
194
+ end
195
+
196
+ def self.find_connection(host, port)
197
+ @connections[[host, port]]
198
+ end
199
+
200
+ def self.remove_connection(host, port)
201
+ if self.has_connection?(host, port)
202
+ ssl, sock = @connections.delete([host, port])
203
+ ssl.close
204
+ sock.close
205
+ end
206
+ end
207
+
208
+ def self.reconnect_connection(host, port)
209
+ self.remove_connection(host, port)
210
+ self.create_connection(host, port)
211
+ end
212
+
213
+ def self.get_connection(host, port)
214
+ if @cache_connections
215
+ # Create a new connection if we don't have one
216
+ unless self.has_connection?(host, port)
217
+ self.create_connection(host, port)
218
+ end
219
+
220
+ ssl, sock = self.find_connection(host, port)
221
+ # If we're closed, reconnect
222
+ if ssl.closed?
223
+ self.reconnect_connection(host, port)
224
+ self.find_connection(host, port)
225
+ else
226
+ return [ssl, sock]
227
+ end
228
+ else
229
+ self.open_connection(host, port)
230
+ end
231
+ end
232
+
233
+ def self.with_connection(host, port, &block)
234
+ retries = 0
235
+ begin
236
+ ssl, sock = self.get_connection(host, port)
237
+ yield ssl if block_given?
238
+
239
+ unless @cache_connections
240
+ ssl.close
241
+ sock.close
242
+ end
243
+ rescue Errno::ECONNABORTED, Errno::EPIPE, Errno::ECONNRESET
244
+ if (retries += 1) < 5
245
+ self.remove_connection(host, port)
246
+ retry
247
+ else
248
+ # too-many retries, re-raise
249
+ raise
250
+ end
251
+ end
252
+ end
253
+
254
+ end
@@ -0,0 +1,32 @@
1
+ # Copyright (c) 2013 William Denniss
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+ module APNS
24
+
25
+ class APNSException < Exception
26
+ end
27
+
28
+ class TrucateException < APNSException
29
+ end
30
+
31
+
32
+ end
@@ -0,0 +1,106 @@
1
+ # Copyright (c) 2013 William Denniss
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ module APNS
25
+
26
+ class Truncate
27
+
28
+ TRUNCATE_METHOD_SOFT = 'soft'
29
+ TRUNCATE_METHOD_HARD = 'hard'
30
+
31
+ NOTIFICATION_MAX_BYTE_SIZE = 256
32
+
33
+ # forces a notification to fit within Apple's payload limits by truncating the message as required
34
+ def self.truncate_notification(notification, clean_whitespace = true, truncate_mode = TRUNCATE_METHOD_SOFT, truncate_soft_max_chopped = 10)
35
+
36
+ raise ArgumentError, "notification is not a hash" unless notification.is_a?(Hash)
37
+ raise ArgumentError, "notification hash should contain :aps key" unless notification[:aps]
38
+
39
+ return notification if !notification[:aps][:alert] || notification[:aps][:alert] == ""
40
+
41
+ # cleans up whitespace
42
+ notification[:aps][:alert].gsub!(/([\s])+/, " ") if clean_whitespace
43
+
44
+ # wd: trims the notification payload to fit in 255 bytes
45
+ if ApnsJSON.apns_json_size(notification) > NOTIFICATION_MAX_BYTE_SIZE
46
+
47
+ oversize_by = ApnsJSON.apns_json_size(notification) - NOTIFICATION_MAX_BYTE_SIZE
48
+ message_target_byte_size = ApnsJSON.apns_json_size(notification[:aps][:alert]) - oversize_by
49
+
50
+ if message_target_byte_size < 0
51
+ raise TrucateException, "notification does not fit within 256 byte limit even by if the message was completely truncated"
52
+ end
53
+ if message_target_byte_size == 0
54
+ raise TrucateException, "notification would only fit within 256 byte limit by completely truncating the message which changes the presentation in iOS"
55
+ end
56
+
57
+ notification[:aps][:alert] = truncate_string(notification[:aps][:alert], message_target_byte_size, truncate_mode, truncate_soft_max_chopped)
58
+ end
59
+
60
+ return notification
61
+ end
62
+
63
+ # truncates a string to a given byte size
64
+ def self.truncate_string(input_string, target_byte_size, truncate_mode = TRUNCATE_METHOD_SOFT, truncate_soft_max_chopped = 10, ellipsis = "\u2026", truncate_soft_regex = /\s/)
65
+
66
+ raise ArgumentError, "Cannot truncate string to a negative number" if target_byte_size < 0
67
+
68
+ return input_string if ApnsJSON.apns_json_size(input_string) <= target_byte_size
69
+
70
+ # if the target size is below the size of the ellipsis, reverts to a hard-truncate with no ellipsis
71
+ if target_byte_size < ApnsJSON.apns_json_size(ellipsis)
72
+ truncate_mode = TRUNCATE_METHOD_HARD
73
+ ellipsis = ''
74
+ end
75
+
76
+ # reduces target-size by the ellipsis size
77
+ target_byte_size -= ApnsJSON.apns_json_size(ellipsis)
78
+
79
+ # starts with a string length equal to the target number bytes (which for an ASCII-only string is the final string)
80
+ string = input_string[0,target_byte_size]
81
+
82
+ # chops off characters one at a time until the byte-size is within our target size, to handle variable-byte char strings
83
+ while ApnsJSON.apns_json_size(string) > target_byte_size
84
+ string = string[0,string.length-1]
85
+ end
86
+
87
+ # further truncates string on whitespace boundaries
88
+ if truncate_mode == TRUNCATE_METHOD_SOFT
89
+
90
+ string = input_string[0, string.length+1] # elongates string by 1 character in case it happens to be whitespace
91
+ trim_to_index = string.rindex(/\s/) # sets trim index to be last whitespace character
92
+ if !trim_to_index || (string.length - trim_to_index) > truncate_soft_max_chopped
93
+ trim_to_index = string.length-1 # cancels soft-truncate if no whitespace, or too much would be chopped
94
+ end
95
+
96
+ string = string[0,trim_to_index]
97
+ end
98
+
99
+ string = '' if string.nil? # if string is nil, it was entirely truncated
100
+
101
+ return string + ellipsis
102
+ end
103
+
104
+ end
105
+
106
+ end
@@ -0,0 +1,3 @@
1
+ module APNS
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,84 @@
1
+ # Copyright (c) 2013 William Denniss
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ require 'spec_helper'
25
+
26
+ describe APNS do
27
+
28
+ it "should encode unicode to ascii-only json" do
29
+ string = "\u2601"
30
+ json = ApnsJSON.apns_json([string])
31
+ json.should == "[\"\u2601\"]"
32
+ end
33
+
34
+ it "should encode 5-byte unicode to JSON in an apple safe manner" do
35
+ string = "\u{1F511}"
36
+ json = ApnsJSON.apns_json([string])
37
+ json.should == "[\"\u{1F511}\"]"
38
+ end
39
+
40
+ it "should return JSON escaped" do
41
+ n = {:aps => {:alert => "\u2601Hello iPhone", :badge => 3, :sound => 'awesome.caf'}}
42
+ json = ApnsJSON.apns_json(n)
43
+ json.should == "{\"aps\":{\"alert\":\"\u2601Hello iPhone\",\"badge\":3,\"sound\":\"awesome.caf\"}}"
44
+ end
45
+
46
+ it "should throw exception if payload is too big" do
47
+ # too big with alert
48
+ notification = {:aps => {:alert => "#{LONG_MESSAGE_QBF}#{LONG_MESSAGE_QBF}", :badge => '1', :sound => 'default'}, :server_info => {:type => 'example', :data => 12345, :something => LONG_MESSAGE_QBF, :again => LONG_MESSAGE_QBF}}
49
+ expect { APNS.packaged_notification("12345678901234567890123456789012", notification, 1, Time.now + 14400) }.to raise_error(APNSException)
50
+
51
+ # too big without alert
52
+ notification = notification = {:aps => {:badge => '1', :sound => 'default'}, :server_info => {:type => 'example', :data => 12345, :something => LONG_MESSAGE_QBF, :again => LONG_MESSAGE_QBF}}
53
+ expect { APNS.packaged_notification("12345678901234567890123456789012", notification, 1, Time.now + 14400) }.to raise_error(APNSException)
54
+ end
55
+
56
+ describe '#packaged_message' do
57
+
58
+ it "should return JSON with notification information" do
59
+ n = APNS.packaged_message({:aps => {:alert => 'Hello iPhone', :badge => 3, :sound => 'awesome.caf'}})
60
+ n.should == "{\"aps\":{\"alert\":\"Hello iPhone\",\"badge\":3,\"sound\":\"awesome.caf\"}}"
61
+ end
62
+
63
+ it "should not include keys that are empty in the JSON" do
64
+ n = APNS.packaged_message({:aps => {:badge => 3}})
65
+ n.should == "{\"aps\":{\"badge\":3}}"
66
+ end
67
+
68
+ end
69
+
70
+ describe '#packaged_token' do
71
+ it "should package the token" do
72
+ n = APNS.packaged_token('<5b51030d d5bad758 fbad5004 bad35c31 e4e0f550 f77f20d4 f737bf8d 3d5524c6>')
73
+ Base64.encode64(n).should == "W1EDDdW611j7rVAEutNcMeTg9VD3fyDU9ze/jT1VJMY=\n"
74
+ end
75
+ end
76
+
77
+ describe '#packaged_notification' do
78
+ it "should package the notification" do
79
+ n = APNS.packaged_notification('device_token', {:aps => {:alert => 'Hello iPhone', :badge => 3, :sound => 'awesome.caf'}}, 1, 1367064263)
80
+ Base64.encode64(n).should == "AQAAAAFRe77HACDe8s79hOcAQHsiYXBzIjp7ImFsZXJ0IjoiSGVsbG8gaVBo\nb25lIiwiYmFkZ2UiOjMsInNvdW5kIjoiYXdlc29tZS5jYWYifX0=\n"
81
+ end
82
+ end
83
+
84
+ end
@@ -0,0 +1,180 @@
1
+ # encoding: UTF-8
2
+
3
+ # Copyright (c) 2013 William Denniss
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person
6
+ # obtaining a copy of this software and associated documentation
7
+ # files (the "Software"), to deal in the Software without
8
+ # restriction, including without limitation the rights to use,
9
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the
11
+ # Software is furnished to do so, subject to the following
12
+ # conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be
15
+ # included in all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ # OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ require 'spec_helper'
27
+
28
+ include APNS
29
+
30
+ describe APNS::Truncate do
31
+
32
+ STRING_1TO9 = "123456789"
33
+ STRING_QBF = "the quick brown fox jumps over the lazy dog"
34
+ STRING_CHINESE = "中国上海北京南京西安东京水茶可乐咖啡"
35
+ ELLIPSIS = "\u2026"
36
+ LONG_MESSAGE_QBF = "#{STRING_QBF}#{STRING_QBF}#{STRING_QBF}#{STRING_QBF}#{STRING_QBF}#{STRING_QBF}#{STRING_QBF}#{STRING_QBF}#{STRING_QBF}"
37
+ LONG_MESSAGE_CHINESE = "#{STRING_CHINESE}#{STRING_CHINESE}#{STRING_CHINESE}#{STRING_CHINESE}"
38
+
39
+ describe '#json_byte_length' do
40
+ it "should return the correct ascii-json byte size" do
41
+ # string sizes
42
+ ApnsJSON.apns_json_size("1").should == 1
43
+ ApnsJSON.apns_json_size("12").should == 2
44
+ ApnsJSON.apns_json_size("\u2026").should == 3
45
+ ApnsJSON.apns_json_size("\u{1F511}").should == 4
46
+
47
+ # object sizes
48
+ ApnsJSON.apns_json_size(["1"]).should == 1+4
49
+ ApnsJSON.apns_json_size(["12"]).should == 2+4
50
+ ApnsJSON.apns_json_size(["\u2026"]).should == 3+4
51
+ ApnsJSON.apns_json_size(["\u{1F511}"]).should == 4+4
52
+ end
53
+ end
54
+
55
+ describe '#truncate_string' do
56
+
57
+ it "should handle byte-sizes smaller than the ellipsis size" do
58
+ s = Truncate.truncate_string(STRING_1TO9, 2)
59
+ s.should == "12"
60
+ end
61
+
62
+ it "should handle byte-sizes equal to the ellipsis size" do
63
+ s = Truncate.truncate_string(STRING_1TO9, ApnsJSON.apns_json_size(ELLIPSIS))
64
+ s.should == ELLIPSIS
65
+
66
+ s = Truncate.truncate_string(STRING_CHINESE, ApnsJSON.apns_json_size(ELLIPSIS))
67
+ s.should == ELLIPSIS
68
+ end
69
+
70
+ it "should handle byte-sizes euqal to the ellipsis size+1" do
71
+ s = Truncate.truncate_string(STRING_1TO9, ApnsJSON.apns_json_size(ELLIPSIS)+1)
72
+ s.should == "1#{ELLIPSIS}"
73
+
74
+ s = Truncate.truncate_string(STRING_CHINESE, ApnsJSON.apns_json_size(ELLIPSIS)+1)
75
+ s.should == ELLIPSIS
76
+ end
77
+
78
+ it "should soft truncate stirngs to equal or below requested size #2" do
79
+
80
+ s = Truncate.truncate_string(STRING_QBF, 8)
81
+ s.bytesize.should <= 8
82
+
83
+ s = Truncate.truncate_string(STRING_CHINESE, 8)
84
+ s.bytesize.should <= 8
85
+
86
+ s = Truncate.truncate_string(STRING_QBF, 35)
87
+ s.bytesize.should <= 35
88
+
89
+ s = Truncate.truncate_string(STRING_CHINESE, 35)
90
+ s.bytesize.should <= 35
91
+ end
92
+
93
+ it "should hard-truncate to the exact str length" do
94
+ s = Truncate.truncate_string(STRING_QBF, 8, Truncate::TRUNCATE_METHOD_HARD, 0, '.')
95
+ s.bytesize.should == 8
96
+ end
97
+
98
+ it "should soft-truncate on word boundaries" do
99
+ s = Truncate.truncate_string(STRING_QBF, 9, Truncate::TRUNCATE_METHOD_SOFT, 10, '.')
100
+ s.should == "the."
101
+ end
102
+
103
+ it "shouldn't soft-truncate any more than needed (even if the space lies on the boundary)" do
104
+ s = Truncate.truncate_string(STRING_QBF, 10, Truncate::TRUNCATE_METHOD_SOFT, 10, '.')
105
+ s.should == "the quick."
106
+ end
107
+
108
+ it "soft truncate should handle strings with no spaces by reverting to hard-truncate" do
109
+ s = Truncate.truncate_string(STRING_1TO9, 8, Truncate::TRUNCATE_METHOD_SOFT, 10, '.')
110
+ s.bytesize.should == 8
111
+ end
112
+
113
+ it "soft truncate should handle unicode strings" do
114
+ s = Truncate.truncate_string(STRING_CHINESE, 20)
115
+ s.should_not == nil
116
+ end
117
+
118
+ it "should handle scripts that don't use whitespace and not soft-truncate more than needed" do
119
+ # soft truncate relies on spaces. Some scripts have no spaces and shouldn't be aversely truncated
120
+ soft = Truncate.truncate_string(STRING_CHINESE, 40, Truncate::TRUNCATE_METHOD_SOFT)
121
+ hard = Truncate.truncate_string(STRING_CHINESE, 40, Truncate::TRUNCATE_METHOD_HARD)
122
+ soft.should == hard
123
+ end
124
+
125
+ it "shouldn't truncate stirngs it doesn't need to" do
126
+ s = APNS::Truncate.truncate_string("123456789", 20)
127
+ s.should == "123456789"
128
+
129
+ s = APNS::Truncate.truncate_string("123456789", 9)
130
+ s.should == "123456789"
131
+
132
+ s = APNS::Truncate.truncate_string(STRING_CHINESE, 200)
133
+ s.should == STRING_CHINESE
134
+ end
135
+
136
+ end
137
+
138
+ describe '#truncate_notification' do
139
+
140
+ it "should truncate notification to size" do
141
+ notification = {:aps => {:alert => LONG_MESSAGE_QBF, :badge => '1', :sound => 'default'}, :server_info => {:type => 'example', :data => 12345, :something => 'blar'}}
142
+ Truncate.truncate_notification(notification)
143
+ ApnsJSON.apns_json(notification).bytesize.should <= 256
144
+
145
+ notification = {:aps => {:alert => LONG_MESSAGE_CHINESE, :badge => '1', :sound => 'default'}, :server_info => {:type => 'example', :data => 12345, :something => 'blar'}}
146
+ Truncate.truncate_notification(notification)
147
+ ApnsJSON.apns_json(notification).bytesize.should <= 256
148
+
149
+ notification = {:aps => {:alert => LONG_MESSAGE_QBF, :badge => '1', :sound => 'default'}, :server_info => {:type => 'example', :data => 12345, :something => 'blar'}}
150
+ Truncate.truncate_notification(notification, true, truncate_mode = Truncate::TRUNCATE_METHOD_HARD)
151
+ ApnsJSON.apns_json(notification).bytesize.should == 256
152
+ end
153
+
154
+ it "should throw exception if the notification cannot be truncated" do
155
+
156
+ notification = {:aps => {:alert => LONG_MESSAGE_QBF, :badge => '1', :sound => 'default'}, :server_info => {:type => 'example', :data => 12345, :something => LONG_MESSAGE_QBF, :again => LONG_MESSAGE_QBF}}
157
+ expect { Truncate.truncate_notification(notification) }.to raise_error
158
+ end
159
+
160
+ it "should throw exception if the notification structure is invalid" do
161
+
162
+ notification = {:alert => LONG_MESSAGE_QBF, :badge => '1', :sound => 'default'}
163
+ expect { Truncate.truncate_notification(notification) }.to raise_error (ArgumentError)
164
+
165
+ notification = [1,2,3]
166
+ expect { Truncate.truncate_notification(notification) }.to raise_error (ArgumentError)
167
+
168
+
169
+ end
170
+
171
+
172
+ it "shouldn't truncate notification unless needed" do
173
+ notification = {:aps => {:alert => STRING_QBF, :badge => '1', :sound => 'default'}, :server_info => {:type => 'example', :data => 12345, :something => 'blar'}}
174
+ Truncate.truncate_notification(notification)
175
+ notification[:aps][:alert].should == STRING_QBF
176
+ end
177
+
178
+ end
179
+
180
+ end
@@ -0,0 +1,2 @@
1
+ require 'apns'
2
+ require 'base64'
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gs-apns
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - William Denniss
9
+ - Paul Gebheim
10
+ - James Pozdena
11
+ autorequire:
12
+ bindir: bin
13
+ cert_chain: []
14
+ date: 2013-04-28 00:00:00.000000000 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: rspec
18
+ requirement: !ruby/object:Gem::Requirement
19
+ none: false
20
+ requirements:
21
+ - - ~>
22
+ - !ruby/object:Gem::Version
23
+ version: '2.6'
24
+ type: :development
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ none: false
28
+ requirements:
29
+ - - ~>
30
+ - !ruby/object:Gem::Version
31
+ version: '2.6'
32
+ description: Simple Apple push notification service gem
33
+ email:
34
+ - will@geospike.com
35
+ executables: []
36
+ extensions: []
37
+ extra_rdoc_files:
38
+ - MIT-LICENSE
39
+ files:
40
+ - .gitignore
41
+ - Gemfile
42
+ - MIT-LICENSE
43
+ - README.textile
44
+ - Rakefile
45
+ - apns.gemspec
46
+ - lib/apns.rb
47
+ - lib/apns/apns_json.rb
48
+ - lib/apns/core.rb
49
+ - lib/apns/exceptions.rb
50
+ - lib/apns/truncate.rb
51
+ - lib/apns/version.rb
52
+ - spec/apns/core_spec.rb
53
+ - spec/apns/truncate_spec.rb
54
+ - spec/spec_helper.rb
55
+ homepage: http://github.com/WilliamDenniss/APNS
56
+ licenses: []
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ! '>='
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ none: false
69
+ requirements:
70
+ - - ! '>='
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubyforge_project:
75
+ rubygems_version: 1.8.21
76
+ signing_key:
77
+ specification_version: 3
78
+ summary: Simple Apple push notification service gem
79
+ test_files:
80
+ - spec/apns/core_spec.rb
81
+ - spec/apns/truncate_spec.rb
82
+ - spec/spec_helper.rb