├── .gitignore ├── MANIFEST ├── README.markdown ├── apns-feedback ├── apns-send ├── apns.py ├── requirements.txt ├── setup.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | apns.py 2 | setup.py 3 | tests.py 4 | README.markdown -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # tornado_apns 2 | 3 | A Python library for interacting with the Apple Push Notification service 4 | (APNs) for tornado async programming 5 | 6 | ## Sample usage 7 | 8 | ```python 9 | import time 10 | from apns import APNs, Payload 11 | from tornado import ioloop 12 | 13 | apns = APNs(use_sandbox=True, cert_file='cert.pem', key_file='key.pem') 14 | 15 | def success(): 16 | print("Sent push message to APNS gateway.") 17 | ioloop.IOLoop.instance().stop() 18 | 19 | def send(): 20 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b87' #device token 21 | payload = Payload(alert="Hello World!", sound="default", badge=1) 22 | identifier = 1 # 一个任意的值,用于识别此消息。如果发送出现问题, 错误应答里会把identifier带回来 23 | expiry = time.time() + 3600 #离线消息超时时间, 如果小于0或等于0, APNS不会保存这条消息 24 | apns.gateway_server.send_notification(identifier, expiry, token_hex, payload, success) 25 | 26 | def on_response(status, seq): 27 | print "sent push message to APNS gateway error status %s seq %s" % (status, seq) 28 | 29 | def on_connected(): 30 | apns.gateway_server.receive_response(on_response) 31 | 32 | # Connect the apns 33 | apns.gateway_server.connect(on_connected) 34 | 35 | # Wait for the connection and send a notification 36 | ioloop.IOLoop.instance().add_timeout(time.time()+5, send) 37 | 38 | ioloop.IOLoop.instance().start() 39 | ``` 40 | 41 | To send multiple notifications in a single transmission, use the Frame class. Example: 42 | 43 | ```python 44 | frame = Frame() 45 | frame.add_item('b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b87', payload, identifier, expiry, priority) 46 | apns.gateway_server.send_notification_multiple(frame, success) 47 | ``` 48 | 49 | For more complicated alerts including custom buttons etc, use the PayloadAlert 50 | class. Example: 51 | 52 | ```python 53 | alert = PayloadAlert("Hello world!", action_loc_key="Click me") 54 | payload = Payload(alert=alert, sound="default") 55 | ``` 56 | 57 | To send custom payload arguments, pass a dictionary to the custom kwarg 58 | of the Payload constructor. 59 | 60 | ```python 61 | payload = Payload(alert="Hello World!", custom={'sekrit_number':123}) 62 | ``` 63 | 64 | ## Further Info 65 | 66 | [iOS Reference Library: Local and Push Notification Programming Guide][a1] 67 | 68 | ## Credits 69 | 70 | Written and maintained by Simon Whitaker at [Goo Software Ltd][goo] and tornado it by kernel1983. 71 | 72 | Also thanks to [Ethan-Zhang](https://github.com/Ethan-Zhang) for contributing. 73 | 74 | [a1]:https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/LegacyFormat.html#//apple_ref/doc/uid/TP40008194-CH105-SW1 75 | [goo]:http://www.goosoftware.co.uk/ 76 | -------------------------------------------------------------------------------- /apns-feedback: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from apns import APNs 4 | 5 | import optparse 6 | 7 | from tornado import ioloop 8 | 9 | parser = optparse.OptionParser() 10 | 11 | parser.add_option("-c", "--certificate-file", 12 | dest="certificate_file", 13 | help="Path to .pem certificate file") 14 | 15 | parser.add_option("-k", "--key-file", 16 | dest="key_file", 17 | help="Path to .pem key file") 18 | 19 | options, args = parser.parse_args() 20 | 21 | if options.certificate_file is None: 22 | parser.error('Must provide --certificate-file') 23 | 24 | if options.key_file is None: 25 | parser.error('Must provide --key-file') 26 | 27 | apns = APNs(cert_file=options.certificate_file, key_file=options.key_file, 28 | use_sandbox=True) 29 | 30 | 31 | def success(token_hex, fail_time): 32 | print("Get feedback data token:%s fail_time:%s" % 33 | (token_hex, fail_time)) 34 | 35 | 36 | def receive(): 37 | apns.feedback_server.receive_feedback(success) 38 | 39 | # receive feedback 40 | apns.feedback_server.connect(receive) 41 | ioloop.IOLoop.instance().start() 42 | -------------------------------------------------------------------------------- /apns-send: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from apns import APNs, Payload 4 | 5 | import optparse 6 | import time 7 | 8 | from tornado import ioloop 9 | 10 | parser = optparse.OptionParser() 11 | 12 | parser.add_option("-c", "--certificate-file", 13 | dest="certificate_file", 14 | help="Path to .pem certificate file") 15 | 16 | parser.add_option("-k", "--key-file", 17 | dest="key_file", 18 | help="Path to .pem key file") 19 | 20 | parser.add_option("-p", "--push-token", 21 | dest="push_token", 22 | help="Push token") 23 | 24 | parser.add_option("-m", "--message", 25 | dest="message", 26 | help="Message") 27 | 28 | options, args = parser.parse_args() 29 | 30 | if options.certificate_file is None: 31 | parser.error('Must provide --certificate-file') 32 | 33 | if options.key_file is None: 34 | parser.error('Must provide --key-file') 35 | 36 | if options.push_token is None: 37 | parser.error('Must provide --push-token') 38 | 39 | if options.message is None: 40 | parser.error('Must provide --message') 41 | 42 | apns = APNs(cert_file=options.certificate_file, key_file=options.key_file, 43 | use_sandbox=True) 44 | 45 | 46 | def success(): 47 | print("Sent push message to APNS gateway.") 48 | ioloop.IOLoop.instance().stop() 49 | 50 | 51 | def send(): 52 | apns.gateway_server.send_notification(0, time.time()+5, options.push_token, 53 | payload, success) 54 | 55 | 56 | def on_response(status, seq): 57 | print "sent push message to APNS gateway error status %s seq %s" % (status, seq) 58 | 59 | 60 | def on_connected(): 61 | apns.gateway_server.receive_response(on_response) 62 | 63 | # Send a notification 64 | payload = Payload(alert=options.message, sound="default", badge=1) 65 | apns.gateway_server.connect(on_connected) 66 | ioloop.IOLoop.instance().add_timeout(time.time()+5, send) 67 | ioloop.IOLoop.instance().start() 68 | -------------------------------------------------------------------------------- /apns.py: -------------------------------------------------------------------------------- 1 | # PyAPNs was developed by Simon Whitaker 2 | # Source available at https://github.com/simonwhitaker/PyAPNs 3 | # 4 | # PyAPNs is distributed under the terms of the MIT license. 5 | # 6 | # Copyright (c) 2011 Goo Software Ltd 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 9 | # this software and associated documentation files (the "Software"), to deal in 10 | # the Software without restriction, including without limitation the rights to 11 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 12 | # of the Software, and to permit persons to whom the Software is furnished to do 13 | # so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in all 16 | # copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | 26 | from binascii import a2b_hex, b2a_hex 27 | import time 28 | from socket import socket, AF_INET, SOCK_STREAM 29 | from struct import pack, unpack 30 | import functools 31 | 32 | try: 33 | import json 34 | except ImportError: 35 | import simplejson as json 36 | 37 | from tornado import iostream 38 | from tornado import ioloop 39 | 40 | MAX_PAYLOAD_LENGTH = 2048 41 | TIME_OUT = 20 42 | 43 | 44 | class APNs(object): 45 | """A class representing an Apple Push Notification service connection""" 46 | 47 | def __init__(self, use_sandbox=False, cert_file=None, key_file=None): 48 | """ 49 | Set use_sandbox to True to use the sandbox (test) APNs servers. 50 | Default is False. 51 | """ 52 | super(APNs, self).__init__() 53 | self.use_sandbox = use_sandbox 54 | self.cert_file = cert_file 55 | self.key_file = key_file 56 | self._feedback_connection = None 57 | self._gateway_connection = None 58 | 59 | @staticmethod 60 | def packed_uchar(num): 61 | """ 62 | Returns an unsigned char in packed form 63 | """ 64 | return pack('>B', num) 65 | 66 | @staticmethod 67 | def unpacked_uchar(bytes): 68 | """ 69 | Returns an unsigned char from a packed (network) byte 70 | """ 71 | return unpack('>B', bytes)[0] 72 | 73 | @staticmethod 74 | def packed_ushort_big_endian(num): 75 | """ 76 | Returns an unsigned short in packed big-endian (network) form 77 | """ 78 | return pack('>H', num) 79 | 80 | @staticmethod 81 | def unpacked_ushort_big_endian(bytes): 82 | """ 83 | Returns an unsigned short from a packed big-endian (network) byte 84 | array 85 | """ 86 | return unpack('>H', bytes)[0] 87 | 88 | @staticmethod 89 | def packed_uint_big_endian(num): 90 | """ 91 | Returns an unsigned int in packed big-endian (network) form 92 | """ 93 | return pack('>I', num) 94 | 95 | @staticmethod 96 | def unpacked_uint_big_endian(bytes): 97 | """ 98 | Returns an unsigned int from a packed big-endian (network) byte array 99 | """ 100 | return unpack('>I', bytes)[0] 101 | 102 | @property 103 | def feedback_server(self): 104 | if not self._feedback_connection: 105 | self._feedback_connection = FeedbackConnection( 106 | use_sandbox=self.use_sandbox, 107 | cert_file=self.cert_file, 108 | key_file=self.key_file 109 | ) 110 | return self._feedback_connection 111 | 112 | @property 113 | def gateway_server(self): 114 | if not self._gateway_connection: 115 | self._gateway_connection = GatewayConnection( 116 | use_sandbox=self.use_sandbox, 117 | cert_file=self.cert_file, 118 | key_file=self.key_file 119 | ) 120 | return self._gateway_connection 121 | 122 | 123 | class APNsConnection(object): 124 | """ 125 | A generic connection class for communicating with the APNs 126 | """ 127 | def __init__(self, cert_file=None, key_file=None): 128 | super(APNsConnection, self).__init__() 129 | self.cert_file = cert_file 130 | self.key_file = key_file 131 | self._socket = None 132 | self._ssl = None 133 | self._stream = None 134 | self._alive = False 135 | self._connecting = False 136 | self._connect_timeout = None 137 | 138 | def __del__(self): 139 | if self._stream: 140 | self._stream.close() 141 | 142 | def is_alive(self): 143 | return self._alive 144 | 145 | def is_connecting(self): 146 | return self._connecting 147 | 148 | def connect(self, callback): 149 | # Establish an SSL connection 150 | if not self._connecting: 151 | self._connecting = True 152 | _ioloop = ioloop.IOLoop.instance() 153 | self._connect_timeout = _ioloop.add_timeout(time.time()+TIME_OUT, 154 | self._connecting_timeout_callback) 155 | self._socket = socket(AF_INET, SOCK_STREAM) 156 | ssl_options = {"keyfile": self.key_file, "certfile": self.cert_file} 157 | self._stream = iostream.SSLIOStream(socket=self._socket, 158 | ssl_options=ssl_options) 159 | self._stream.connect((self.server, self.port), 160 | functools.partial(self._on_connected, callback)) 161 | 162 | def _connecting_timeout_callback(self): 163 | self._connecting = False 164 | if not self.is_alive(): 165 | self.disconnect() 166 | raise ConnectionError('connect timeout') 167 | 168 | def _on_connected(self, callback): 169 | ioloop.IOLoop.instance().remove_timeout(self._connect_timeout) 170 | self._alive = True 171 | self._connecting = False 172 | callback() 173 | 174 | def disconnect(self): 175 | self._alive = False 176 | self._stream.close() 177 | 178 | def set_close_callback(self, callback): 179 | self._stream_close_final_callback = callback 180 | self._stream.set_close_callback(self._stream_close_callback) 181 | 182 | def _stream_close_callback(self): 183 | self._alive = False 184 | self._stream_close_final_callback() 185 | 186 | def read(self, n, callback): 187 | try: 188 | self._stream.read_bytes(n, callback) 189 | except (AttributeError, IOError) as e: 190 | self.disconnect() 191 | raise ConnectionError('%s' % e) 192 | 193 | def read_till_close(self, callback): 194 | try: 195 | self._stream.read_until_close(callback=callback, streaming_callback=callback) 196 | except (AttributeError, IOError) as e: 197 | self.disconnect() 198 | raise ConnectionError('%s' % e) 199 | 200 | def write(self, string, callback): 201 | try: 202 | self._stream.write(string, callback) 203 | except (AttributeError, IOError) as e: 204 | self.disconnect() 205 | raise ConnectionError('%s' % e) 206 | 207 | 208 | class PayloadAlert(object): 209 | def __init__(self, body, action_loc_key=None, loc_key=None, 210 | loc_args=None, launch_image=None): 211 | super(PayloadAlert, self).__init__() 212 | self.body = body 213 | self.action_loc_key = action_loc_key 214 | self.loc_key = loc_key 215 | self.loc_args = loc_args 216 | self.launch_image = launch_image 217 | 218 | def dict(self): 219 | d = {'body': self.body} 220 | if self.action_loc_key: 221 | d['action-loc-key'] = self.action_loc_key 222 | if self.loc_key: 223 | d['loc-key'] = self.loc_key 224 | if self.loc_args: 225 | d['loc-args'] = self.loc_args 226 | if self.launch_image: 227 | d['launch-image'] = self.launch_image 228 | return d 229 | 230 | 231 | class PayloadTooLargeError(Exception): 232 | def __init__(self): 233 | super(PayloadTooLargeError, self).__init__() 234 | 235 | 236 | class TokenLengthOddError(Exception): 237 | pass 238 | 239 | 240 | class ConnectionError(Exception): 241 | pass 242 | 243 | 244 | class Payload(object): 245 | """A class representing an APNs message payload""" 246 | def __init__(self, alert=None, badge=None, sound=None, 247 | content_available=False, custom={}): 248 | super(Payload, self).__init__() 249 | self.alert = alert 250 | self.badge = badge 251 | self.sound = sound 252 | self.content_available = content_available 253 | self.custom = custom 254 | self._check_size() 255 | 256 | def dict(self): 257 | """Returns the payload as a regular Python dictionary""" 258 | d = {} 259 | if self.alert: 260 | # Alert can be either a string or a PayloadAlert 261 | # object 262 | if isinstance(self.alert, PayloadAlert): 263 | d['alert'] = self.alert.dict() 264 | else: 265 | d['alert'] = self.alert 266 | if self.sound: 267 | d['sound'] = self.sound 268 | if self.badge is not None: 269 | d['badge'] = int(self.badge) 270 | if self.content_available: 271 | d.update({'content-available': 1}) 272 | 273 | d = {'aps': d} 274 | d.update(self.custom) 275 | return d 276 | 277 | def json(self): 278 | return json.dumps(self.dict(), separators=(',', ':'), 279 | ensure_ascii=False).encode('utf-8') 280 | 281 | def _check_size(self): 282 | if len(self.json()) > MAX_PAYLOAD_LENGTH: 283 | raise PayloadTooLargeError() 284 | 285 | def __repr__(self): 286 | attrs = ("alert", "badge", "sound", "custom") 287 | args = ", ".join(["%s=%r" % (n, getattr(self, n)) for n in attrs]) 288 | return "%s(%s)" % (self.__class__.__name__, args) 289 | 290 | 291 | class Frame(object): 292 | """A class representing an APNs message frame for multiple sending""" 293 | def __init__(self): 294 | self.frame_data = bytearray() 295 | 296 | def add_item(self, token_hex, payload, identifier, expiry, priority): 297 | """Add a notification message to the frame""" 298 | item_len = 0 299 | self.frame_data.extend('\2' + APNs.packed_uint_big_endian(item_len)) 300 | 301 | try: 302 | token_bin = a2b_hex(token_hex) 303 | except TypeError: 304 | raise TokenLengthOddError("Token Length is Odd") 305 | token_length_bin = APNs.packed_ushort_big_endian(len(token_bin)) 306 | token_item = '\1' + token_length_bin + token_bin 307 | self.frame_data.extend(token_item) 308 | item_len += len(token_item) 309 | 310 | if isinstance(payload, Payload): 311 | payload_json = payload.json() 312 | else: 313 | payload_json = payload 314 | payload_length_bin = APNs.packed_ushort_big_endian(len(payload_json)) 315 | payload_item = '\2' + payload_length_bin + payload_json 316 | self.frame_data.extend(payload_item) 317 | item_len += len(payload_item) 318 | 319 | identifier_bin = APNs.packed_uint_big_endian(identifier) 320 | identifier_length_bin = APNs.packed_ushort_big_endian(len(identifier_bin)) 321 | identifier_item = '\3' + identifier_length_bin + identifier_bin 322 | self.frame_data.extend(identifier_item) 323 | item_len += len(identifier_item) 324 | 325 | expiry_bin = APNs.packed_uint_big_endian(expiry) 326 | expiry_length_bin = APNs.packed_ushort_big_endian(len(expiry_bin)) 327 | expiry_item = '\4' + expiry_length_bin + expiry_bin 328 | self.frame_data.extend(expiry_item) 329 | item_len += len(expiry_item) 330 | 331 | priority_bin = APNs.packed_uchar(priority) 332 | priority_length_bin = APNs.packed_ushort_big_endian(len(priority_bin)) 333 | priority_item = '\5' + priority_length_bin + priority_bin 334 | self.frame_data.extend(priority_item) 335 | item_len += len(priority_item) 336 | 337 | self.frame_data[-item_len-4:-item_len] = APNs.packed_uint_big_endian(item_len) 338 | 339 | def __str__(self): 340 | """Get the frame buffer""" 341 | return str(self.frame_data) 342 | 343 | 344 | class FeedbackConnection(APNsConnection): 345 | """ 346 | A class representing a connection to the APNs Feedback server 347 | """ 348 | def __init__(self, use_sandbox=False, **kwargs): 349 | super(FeedbackConnection, self).__init__(**kwargs) 350 | self.server = ( 351 | 'feedback.push.apple.com', 352 | 'feedback.sandbox.push.apple.com')[use_sandbox] 353 | self.port = 2196 354 | self.buff = '' 355 | 356 | def __del__(self): 357 | super(FeedbackConnection, self).__del__() 358 | 359 | def receive_feedback(self, callback): 360 | self.read_till_close(functools.partial(self._feedback_callback, callback)) 361 | 362 | def _feedback_callback(self, callback, data): 363 | 364 | self.buff += data 365 | 366 | if len(self.buff) < 6: 367 | return 368 | 369 | while len(self.buff) > 6: 370 | token_length = APNs.unpacked_ushort_big_endian(self.buff[4:6]) 371 | bytes_to_read = 6 + token_length 372 | if len(self.buff) >= bytes_to_read: 373 | fail_time_unix = APNs.unpacked_uint_big_endian(self.buff[0:4]) 374 | token = b2a_hex(self.buff[6:bytes_to_read]) 375 | 376 | callback(token, fail_time_unix) 377 | 378 | # Remove data for current token from buffer 379 | self.buff = self.buff[bytes_to_read:] 380 | else: 381 | return 382 | 383 | 384 | class GatewayConnection(APNsConnection): 385 | """ 386 | A class that represents a connection to the APNs gateway server 387 | """ 388 | def __init__(self, use_sandbox=False, **kwargs): 389 | super(GatewayConnection, self).__init__(**kwargs) 390 | self.server = ( 391 | 'gateway.push.apple.com', 392 | 'gateway.sandbox.push.apple.com')[use_sandbox] 393 | self.port = 2195 394 | 395 | def __del__(self): 396 | super(GatewayConnection, self).__del__() 397 | 398 | def _get_notification(self, identifier, expiry, token_hex, payload): 399 | """ 400 | Takes a token as a hex string and a payload as a Python dict and sends 401 | the notification 402 | """ 403 | try: 404 | token_bin = a2b_hex(token_hex) 405 | except TypeError: 406 | raise TokenLengthOddError("Token Length is Odd") 407 | token_length_bin = APNs.packed_ushort_big_endian(len(token_bin)) 408 | identifier_bin = APNs.packed_uint_big_endian(identifier) 409 | expiry = APNs.packed_uint_big_endian(expiry) 410 | if isinstance(payload, Payload): 411 | payload_json = payload.json() 412 | else: 413 | payload_json = payload 414 | payload_length_bin = APNs.packed_ushort_big_endian(len(payload_json)) 415 | 416 | notification = ('\1' + identifier_bin + expiry + token_length_bin + token_bin + 417 | payload_length_bin + payload_json) 418 | 419 | return notification 420 | 421 | def send_notification(self, identifier, expiry, token_hex, payload, callback): 422 | self.write(self._get_notification(identifier, expiry, token_hex, payload), 423 | callback) 424 | 425 | def send_notification_multiple(self, frame, callback): 426 | self.write(str(frame), callback) 427 | 428 | def receive_response(self, callback): 429 | ''' 430 | receive the error response, return the error status and seq id 431 | ''' 432 | def _read_response_call(callback, data): 433 | APNs.unpacked_uchar(data[0]) 434 | status = APNs.unpacked_uchar(data[1]) 435 | seq = APNs.unpacked_uint_big_endian(data[2:6]) 436 | callback(status, seq) 437 | 438 | self.read(6, functools.partial(_read_response_call, callback)) 439 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | simplejson==2.6.1 # Not required on Python 2.6+ 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | description = 'A python library for interacting with the Apple Push Notification Service' 4 | setup( 5 | author='Simon Whitaker', 6 | author_email='simon@goosoftware.co.uk', 7 | description=description, 8 | download_url='http://github.com/simonwhitaker/PyAPNs', 9 | license='unlicense.org', 10 | name='apns', 11 | py_modules=['apns'], 12 | scripts=['apns-send'], 13 | url='http://www.goosoftware.co.uk/', 14 | version='1.1.2', 15 | ) 16 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | from apns import APNs, Frame, Payload, PayloadAlert, \ 4 | PayloadTooLargeError, MAX_PAYLOAD_LENGTH 5 | from binascii import a2b_hex 6 | from random import random 7 | 8 | import hashlib 9 | import time 10 | import unittest 11 | 12 | # replace with path to test certificate 13 | TEST_CERTIFICATE = "certificate.pem" 14 | 15 | 16 | class TestAPNs(unittest.TestCase): 17 | """Unit tests for PyAPNs""" 18 | 19 | def setUp(self): 20 | """docstring for setUp""" 21 | pass 22 | 23 | def tearDown(self): 24 | """docstring for tearDown""" 25 | pass 26 | 27 | def testConfigs(self): 28 | apns_test = APNs(use_sandbox=True) 29 | apns_prod = APNs(use_sandbox=False) 30 | 31 | self.assertEqual(apns_test.gateway_server.port, 2195) 32 | self.assertEqual(apns_test.gateway_server.server, 33 | 'gateway.sandbox.push.apple.com') 34 | self.assertEqual(apns_test.feedback_server.port, 2196) 35 | self.assertEqual(apns_test.feedback_server.server, 36 | 'feedback.sandbox.push.apple.com') 37 | 38 | self.assertEqual(apns_prod.gateway_server.port, 2195) 39 | self.assertEqual(apns_prod.gateway_server.server, 40 | 'gateway.push.apple.com') 41 | self.assertEqual(apns_prod.feedback_server.port, 2196) 42 | self.assertEqual(apns_prod.feedback_server.server, 43 | 'feedback.push.apple.com') 44 | 45 | def testGatewayServer(self): 46 | pem_file = TEST_CERTIFICATE 47 | apns = APNs(use_sandbox=True, cert_file=pem_file, key_file=pem_file) 48 | gateway_server = apns.gateway_server 49 | 50 | self.assertEqual(gateway_server.cert_file, apns.cert_file) 51 | self.assertEqual(gateway_server.key_file, apns.key_file) 52 | 53 | identifier = 1 54 | expiry = 3600 55 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c' 56 | payload = Payload(alert="Hello World!", 57 | sound="default", 58 | badge=4) 59 | notification = gateway_server._get_notification(identifier, expiry, 60 | token_hex, payload) 61 | 62 | expected_length = ( 63 | 1 + # leading null byte 64 | 4 + # length of identifier as a packed ushort 65 | 4 + # length of expiry time as a packed ushort 66 | 2 + # length of token as a packed short 67 | len(token_hex) / 2 + # length of token as binary string 68 | 2 + # length of payload as a packed short 69 | len(payload.json()) # length of JSON-formatted payload 70 | ) 71 | 72 | self.assertEqual(len(notification), expected_length) 73 | self.assertEqual(notification[0], '\1') 74 | 75 | def testFeedbackServer(self): 76 | pem_file = TEST_CERTIFICATE 77 | apns = APNs(use_sandbox=True, cert_file=pem_file, key_file=pem_file) 78 | feedback_server = apns.feedback_server 79 | 80 | self.assertEqual(feedback_server.cert_file, apns.cert_file) 81 | self.assertEqual(feedback_server.key_file, apns.key_file) 82 | 83 | token_hex = hashlib.sha256("%.12f" % random()).hexdigest() 84 | token_bin = a2b_hex(token_hex) 85 | token_length = len(token_bin) 86 | now_time = int(time.time()) 87 | data = '' 88 | data += APNs.packed_uint_big_endian(now_time) 89 | data += APNs.packed_ushort_big_endian(token_length) 90 | data += token_bin 91 | 92 | def test_callback(token, fail_time): 93 | self.assertEqual(token, token_hex) 94 | self.assertEqual(fail_time, now_time) 95 | 96 | feedback_server._feedback_callback(test_callback, data) 97 | 98 | def testPayloadAlert(self): 99 | pa = PayloadAlert('foo') 100 | d = pa.dict() 101 | self.assertEqual(d['body'], 'foo') 102 | self.assertFalse('action-loc-key' in d) 103 | self.assertFalse('loc-key' in d) 104 | self.assertFalse('loc-args' in d) 105 | self.assertFalse('launch-image' in d) 106 | 107 | pa = PayloadAlert('foo', action_loc_key='bar', loc_key='wibble', 108 | loc_args=['king', 'kong'], launch_image='wobble') 109 | d = pa.dict() 110 | self.assertEqual(d['body'], 'foo') 111 | self.assertEqual(d['action-loc-key'], 'bar') 112 | self.assertEqual(d['loc-key'], 'wibble') 113 | self.assertEqual(d['loc-args'], ['king', 'kong']) 114 | self.assertEqual(d['launch-image'], 'wobble') 115 | 116 | def testPayload(self): 117 | # Payload with just alert 118 | p = Payload(alert=PayloadAlert('foo')) 119 | d = p.dict() 120 | self.assertTrue('alert' in d['aps']) 121 | self.assertTrue('sound' not in d['aps']) 122 | self.assertTrue('badge' not in d['aps']) 123 | 124 | # Payload with just sound 125 | p = Payload(sound="foo") 126 | d = p.dict() 127 | self.assertTrue('sound' in d['aps']) 128 | self.assertTrue('alert' not in d['aps']) 129 | self.assertTrue('badge' not in d['aps']) 130 | 131 | # Payload with just badge 132 | p = Payload(badge=1) 133 | d = p.dict() 134 | self.assertTrue('badge' in d['aps']) 135 | self.assertTrue('alert' not in d['aps']) 136 | self.assertTrue('sound' not in d['aps']) 137 | 138 | # Payload with just content_available 139 | p = Payload(content_available=True) 140 | d = p.dict() 141 | self.assertTrue('content-available' in d['aps']) 142 | self.assertTrue('badge' not in d['aps']) 143 | self.assertTrue('alert' not in d['aps']) 144 | self.assertTrue('sound' not in d['aps']) 145 | 146 | # Payload with just badge removal 147 | p = Payload(badge=0) 148 | d = p.dict() 149 | self.assertTrue('badge' in d['aps']) 150 | self.assertTrue('alert' not in d['aps']) 151 | self.assertTrue('sound' not in d['aps']) 152 | 153 | # Test plain string alerts 154 | alert_str = 'foobar' 155 | p = Payload(alert=alert_str) 156 | d = p.dict() 157 | self.assertEqual(d['aps']['alert'], alert_str) 158 | self.assertTrue('sound' not in d['aps']) 159 | self.assertTrue('badge' not in d['aps']) 160 | 161 | # Test custom payload 162 | alert_str = 'foobar' 163 | custom_dict = {'foo': 'bar'} 164 | p = Payload(alert=alert_str, custom=custom_dict) 165 | d = p.dict() 166 | self.assertEqual(d, {'foo': 'bar', 'aps': {'alert': 'foobar'}}) 167 | 168 | def testFrame(self): 169 | 170 | identifier = 1 171 | expiry = 3600 172 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c' 173 | payload = Payload(alert="Hello World!", 174 | sound="default", 175 | badge=4) 176 | priority = 10 177 | 178 | frame = Frame() 179 | frame.add_item(token_hex, payload, identifier, expiry, priority) 180 | 181 | f = ('\x02\x00\x00\x00t\x01\x00 \xb5\xbb\x9d\x80\x14\xa0\xf9\xb1\xd6\x1e!' 182 | '\xe7\x96\xd7\x8d\xcc\xdf\x13R\xf2<\xd3(\x12\xf4\x85\x0b\x87\x8a\xe4\x94L' 183 | '\x02\x00<{"aps":{"sound":"default","badge":4,"alert":"Hello World!"}}' 184 | '\x03\x00\x04\x00\x00\x00\x01\x04\x00\x04\x00\x00\x0e\x10\x05\x00\x01\n') 185 | self.assertEqual(f, str(frame)) 186 | 187 | def testPayloadTooLargeError(self): 188 | # The maximum size of the JSON payload is MAX_PAYLOAD_LENGTH 189 | # bytes. First determine how many bytes this allows us in the 190 | # raw payload (i.e. before JSON serialisation) 191 | json_overhead_bytes = len(Payload('.').json()) - 1 192 | max_raw_payload_bytes = MAX_PAYLOAD_LENGTH - json_overhead_bytes 193 | 194 | # Test ascii characters payload 195 | Payload('.' * max_raw_payload_bytes) 196 | self.assertRaises(PayloadTooLargeError, Payload, 197 | '.' * (max_raw_payload_bytes + 1)) 198 | 199 | # Test unicode 2-byte characters payload 200 | Payload(u'\u0100' * int(max_raw_payload_bytes / 2)) 201 | self.assertRaises(PayloadTooLargeError, Payload, 202 | u'\u0100' * (int(max_raw_payload_bytes / 2) + 1)) 203 | 204 | if __name__ == '__main__': 205 | unittest.main() 206 | --------------------------------------------------------------------------------