├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST ├── README.markdown ├── apns-send ├── apns.py ├── setup.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build 3 | dist 4 | .project 5 | .pydevproject 6 | .idea 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "2.6" 5 | script: "python tests.py" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2014 Goo Software Ltd 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | # this software and associated documentation files (the "Software"), to deal in 8 | # the Software without restriction, including without limitation the rights to 9 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 10 | # of the Software, and to permit persons to whom the Software is furnished to do 11 | # so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | apns.py 2 | setup.py 3 | tests.py 4 | README.markdown 5 | apns-send 6 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # PyAPNs 2 | 3 | A Python library for interacting with the Apple Push Notification service 4 | (APNs) 5 | 6 | ## Installation 7 | 8 | Either download the source from GitHub or use easy_install: 9 | 10 | $ easy_install apns 11 | 12 | ## Sample usage 13 | 14 | ```python 15 | import time 16 | from apns import APNs, Frame, Payload 17 | 18 | apns = APNs(use_sandbox=True, cert_file='cert.pem', key_file='key.pem') 19 | 20 | # Send a notification 21 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b87' 22 | payload = Payload(alert="Hello World!", sound="default", badge=1) 23 | apns.gateway_server.send_notification(token_hex, payload) 24 | 25 | # Send an iOS 10 compatible notification 26 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b87' 27 | payload = Payload(alert="Hello World!", sound="default", badge=1, mutable_content=True) 28 | apns.gateway_server.send_notification(token_hex, payload) 29 | 30 | # Send multiple notifications in a single transmission 31 | frame = Frame() 32 | identifier = 1 33 | expiry = time.time()+3600 34 | priority = 10 35 | frame.add_item('b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b87', payload, identifier, expiry, priority) 36 | apns.gateway_server.send_notification_multiple(frame) 37 | ``` 38 | 39 | Apple recommends to query the feedback service daily to get the list of device tokens. You need to create a new connection to APNS to see all the tokens that have failed since you only receive that information upon connection. Remember, once you have viewed the list of tokens, Apple will clear the list from their servers. Use the timestamp to verify that the device tokens haven’t been reregistered since the feedback entry was generated. For each device that has not been reregistered, stop sending notifications. By using this information to stop sending push notifications that will fail to be delivered, you reduce unnecessary message overhead and improve overall system performance. 40 | 41 | ``` 42 | #New APNS connection 43 | feedback_connection = APNs(use_sandbox=True, cert_file='cert.pem', key_file='key.pem') 44 | 45 | # Get feedback messages. 46 | for (token_hex, fail_time) in feedback_connection.feedback_server.items(): 47 | # do stuff with token_hex and fail_time 48 | ``` 49 | 50 | 51 | For more complicated alerts including custom buttons etc, use the PayloadAlert 52 | class. Example: 53 | 54 | ```python 55 | alert = PayloadAlert("Hello world!", action_loc_key="Click me") 56 | payload = Payload(alert=alert, sound="default") 57 | ``` 58 | 59 | To send custom payload arguments, pass a dictionary to the custom kwarg 60 | of the Payload constructor. 61 | 62 | ```python 63 | payload = Payload(alert="Hello World!", custom={'sekrit_number':123}) 64 | ``` 65 | 66 | ### Enhanced Message with immediate error-response 67 | ```python 68 | apns_enhanced = APNs(use_sandbox=True, cert_file='apns.pem', enhanced=True) 69 | ``` 70 | 71 | Send a notification. note that `identifer` is the information to indicate which message has error in error-response payload, it should be **UNIQUE** since PyAPNs will also use it to determine the range of messages to be re-sent. 72 | ```python 73 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b87' 74 | payload = Payload(alert="Hello World!", sound="default", badge=1) 75 | identifier = random.getrandbits(32) 76 | apns_enhanced.gateway_server.send_notification(token_hex, payload, identifier=identifier) 77 | ``` 78 | 79 | Callback when error-response occur, with parameter `{'status': , 'identifier': }` 80 | [Status code reference](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW4) 81 | ```python 82 | def response_listener(error_response): 83 | _logger.debug("client get error-response: " + str(error_response)) 84 | 85 | apns_enhanced.gateway_server.register_response_listener(response_listener) 86 | ``` 87 | 88 | Error response worker will be auto-close after 30 secs idle of connection operations. 89 | If you want disable APNS connection and error-responses handler immediately, force_close it. 90 | ```python 91 | apns_enhanced.gateway_server.force_close() 92 | ``` 93 | 94 | Extra log messages when error-response occur, auto-resent afterwards. 95 | 96 | got error-response from APNS:(8, 1) 97 | rebuilding connection to APNS 98 | resending 9 notifications to APNS 99 | resending notification with id:2 to APNS 100 | resending notification with id:3 to APNS 101 | resending notification with id:4 to APNS 102 | 103 | Caveats: 104 | 105 | * Currently support single notification only 106 | 107 | Problem Addressed ([Reference to Redth](http://redth.codes/the-problem-with-apples-push-notification-ser/)): 108 | 109 | * Async response of error response and response time varies from 0.1 ~ 0.8 secs by observation 110 | * Sent success do not response, which means client cannot always expect for response. 111 | * Async close write stream connection after error-response. 112 | * All notification sent after failed notification are discarded, the responding error-response and closing client's write connection will be delayed 113 | * Sometimes APNS close socket connection arbitrary 114 | 115 | Solution: 116 | 117 | * Non-blocking ssl socket connection to send notification without waiting for response. 118 | * A separate thread for constantly checking error-response from read connection. 119 | * A sent notification buffer used for re-sending notification that were sent after failed notification, or arbitrary connection close by apns. 120 | * Reference to [non-blocking apns pull request by minorblend](https://github.com/djacobs/PyAPNs/pull/25), [enhanced message by hagino3000](https://github.com/voyagegroup/apns-proxy-server/blob/065775f87dbf25f6b06f24edc73dc5de4481ad36/apns_proxy_server/worker.py#l164-209) 121 | 122 | Result: 123 | 124 | * Send notification at throughput of 1000/secs 125 | * In worse case of when 1st notification sent failed, error-response respond after 1 secs and 999 notification sent are discarded by APNS at the mean time, all discarded 999 notifications will be resent without loosing any of them. With the same logic, if notification resent failed, it will resent rest of resent notification after the failed one. 126 | 127 | ## Test ## 128 | * [Test Script](https://gist.github.com/jimhorng/594401f68ce48282ced5) 129 | 130 | ## Travis Build Status 131 | 132 | [![Build Status](https://secure.travis-ci.org/djacobs/PyAPNs.png?branch=master)](http://travis-ci.org/djacobs/PyAPNs) 133 | 134 | ## Further Info 135 | 136 | [iOS Reference Library: Local and Push Notification Programming Guide][a1] 137 | 138 | ## License 139 | 140 | PyAPNs is distributed under the terms of the MIT license. 141 | 142 | See [LICENSE](LICENSE) file for the complete license details. 143 | 144 | ## Credits 145 | 146 | Written and maintained by Simon Whitaker at [Goo Software Ltd][goo]. 147 | 148 | [a1]:https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/index.html#//apple_ref/doc/uid/TP40008194-CH3-SW1 149 | [goo]:http://www.goosoftware.co.uk/ 150 | -------------------------------------------------------------------------------- /apns-send: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from apns import APNs, Payload 4 | 5 | import optparse 6 | 7 | parser = optparse.OptionParser() 8 | 9 | parser.add_option("-c", "--certificate-file", 10 | dest="certificate_file", 11 | help="Path to .pem certificate file") 12 | 13 | parser.add_option("-k", "--key-file", 14 | dest="key_file", 15 | help="Path to .pem key file") 16 | 17 | parser.add_option("-p", "--push-token", 18 | dest="push_token", 19 | help="Push token") 20 | 21 | parser.add_option("-m", "--message", 22 | dest="message", 23 | help="Message") 24 | 25 | parser.add_option("-s", "--sandbox", 26 | action="store_true", dest="sandbox", default=False, 27 | help="Use apple sandbox (dev push certificates)") 28 | 29 | options, args = parser.parse_args() 30 | 31 | if options.certificate_file is None: 32 | parser.error('Must provide --certificate-file') 33 | 34 | if options.push_token is None: 35 | parser.error('Must provide --push-token') 36 | 37 | if options.message is None: 38 | parser.error('Must provide --message') 39 | 40 | apns = APNs(use_sandbox=options.sandbox, cert_file=options.certificate_file, key_file=options.key_file) 41 | 42 | # Send a notification 43 | payload = Payload(alert=options.message, sound="default", badge=1) 44 | apns.gateway_server.send_notification(options.push_token, payload) 45 | print("Sent push message to APNS gateway.") 46 | -------------------------------------------------------------------------------- /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 | from datetime import datetime 28 | from socket import socket, timeout, AF_INET, SOCK_STREAM 29 | from socket import error as socket_error 30 | from struct import pack, unpack 31 | import sys 32 | import ssl 33 | import select 34 | import time 35 | import collections, itertools 36 | import logging 37 | import threading 38 | try: 39 | from ssl import wrap_socket, SSLError 40 | except ImportError: 41 | from socket import ssl as wrap_socket, sslerror as SSLError 42 | 43 | from _ssl import SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE 44 | 45 | import json 46 | 47 | _logger = logging.getLogger(__name__) 48 | 49 | MAX_PAYLOAD_LENGTH = 2048 50 | 51 | NOTIFICATION_COMMAND = 0 52 | ENHANCED_NOTIFICATION_COMMAND = 1 53 | 54 | NOTIFICATION_FORMAT = ( 55 | '!' # network big-endian 56 | 'B' # command 57 | 'H' # token length 58 | '32s' # token 59 | 'H' # payload length 60 | '%ds' # payload 61 | ) 62 | 63 | ENHANCED_NOTIFICATION_FORMAT = ( 64 | '!' # network big-endian 65 | 'B' # command 66 | 'I' # identifier 67 | 'I' # expiry 68 | 'H' # token length 69 | '32s' # token 70 | 'H' # payload length 71 | '%ds' # payload 72 | ) 73 | 74 | ERROR_RESPONSE_FORMAT = ( 75 | '!' # network big-endian 76 | 'B' # command 77 | 'B' # status 78 | 'I' # identifier 79 | ) 80 | 81 | TOKEN_LENGTH = 32 82 | ERROR_RESPONSE_LENGTH = 6 83 | DELAY_RESEND_SEC = 0.0 84 | SENT_BUFFER_QTY = 100000 85 | WAIT_WRITE_TIMEOUT_SEC = 10 86 | WAIT_READ_TIMEOUT_SEC = 10 87 | WRITE_RETRY = 3 88 | 89 | ER_STATUS = 'status' 90 | ER_IDENTIFER = 'identifier' 91 | 92 | class APNs(object): 93 | """A class representing an Apple Push Notification service connection""" 94 | 95 | def __init__(self, use_sandbox=False, cert_file=None, key_file=None, enhanced=False): 96 | """ 97 | Set use_sandbox to True to use the sandbox (test) APNs servers. 98 | Default is False. 99 | """ 100 | super(APNs, self).__init__() 101 | self.use_sandbox = use_sandbox 102 | self.cert_file = cert_file 103 | self.key_file = key_file 104 | self._feedback_connection = None 105 | self._gateway_connection = None 106 | self.enhanced = enhanced 107 | 108 | @staticmethod 109 | def packed_uchar(num): 110 | """ 111 | Returns an unsigned char in packed form 112 | """ 113 | return pack('>B', num) 114 | 115 | @staticmethod 116 | def packed_ushort_big_endian(num): 117 | """ 118 | Returns an unsigned short in packed big-endian (network) form 119 | """ 120 | return pack('>H', num) 121 | 122 | @staticmethod 123 | def unpacked_ushort_big_endian(bytes): 124 | """ 125 | Returns an unsigned short from a packed big-endian (network) byte 126 | array 127 | """ 128 | return unpack('>H', bytes)[0] 129 | 130 | @staticmethod 131 | def packed_uint_big_endian(num): 132 | """ 133 | Returns an unsigned int in packed big-endian (network) form 134 | """ 135 | return pack('>I', num) 136 | 137 | @staticmethod 138 | def unpacked_uint_big_endian(bytes): 139 | """ 140 | Returns an unsigned int from a packed big-endian (network) byte array 141 | """ 142 | return unpack('>I', bytes)[0] 143 | 144 | @staticmethod 145 | def unpacked_char_big_endian(bytes): 146 | """ 147 | Returns an unsigned char from a packed big-endian (network) byte array 148 | """ 149 | return unpack('c', bytes)[0] 150 | 151 | @property 152 | def feedback_server(self): 153 | if not self._feedback_connection: 154 | self._feedback_connection = FeedbackConnection( 155 | use_sandbox = self.use_sandbox, 156 | cert_file = self.cert_file, 157 | key_file = self.key_file 158 | ) 159 | return self._feedback_connection 160 | 161 | @property 162 | def gateway_server(self): 163 | if not self._gateway_connection: 164 | self._gateway_connection = GatewayConnection( 165 | use_sandbox = self.use_sandbox, 166 | cert_file = self.cert_file, 167 | key_file = self.key_file, 168 | enhanced = self.enhanced 169 | ) 170 | return self._gateway_connection 171 | 172 | 173 | class APNsConnection(object): 174 | """ 175 | A generic connection class for communicating with the APNs 176 | """ 177 | def __init__(self, cert_file=None, key_file=None, timeout=None, enhanced=False): 178 | super(APNsConnection, self).__init__() 179 | self.cert_file = cert_file 180 | self.key_file = key_file 181 | self.timeout = timeout 182 | self._socket = None 183 | self._ssl = None 184 | self.enhanced = enhanced 185 | self.connection_alive = False 186 | 187 | def _connect(self): 188 | # Establish an SSL connection 189 | _logger.debug("%s APNS connection establishing..." % self.__class__.__name__) 190 | 191 | # Fallback for socket timeout. 192 | for i in range(3): 193 | try: 194 | self._socket = socket(AF_INET, SOCK_STREAM) 195 | self._socket.settimeout(self.timeout) 196 | self._socket.connect((self.server, self.port)) 197 | break 198 | except timeout: 199 | pass 200 | except: 201 | raise 202 | 203 | if self.enhanced: 204 | self._last_activity_time = time.time() 205 | self._socket.setblocking(False) 206 | self._ssl = wrap_socket(self._socket, self.key_file, self.cert_file, 207 | do_handshake_on_connect=False) 208 | while True: 209 | try: 210 | self._ssl.do_handshake() 211 | break 212 | except ssl.SSLError as err: 213 | if ssl.SSL_ERROR_WANT_READ == err.args[0]: 214 | select.select([self._ssl], [], []) 215 | elif ssl.SSL_ERROR_WANT_WRITE == err.args[0]: 216 | select.select([], [self._ssl], []) 217 | else: 218 | raise 219 | 220 | else: 221 | # Fallback for 'SSLError: _ssl.c:489: The handshake operation timed out' 222 | for i in range(3): 223 | try: 224 | self._ssl = wrap_socket(self._socket, self.key_file, self.cert_file) 225 | break 226 | except SSLError as ex: 227 | if ex.args[0] == SSL_ERROR_WANT_READ: 228 | sys.exc_clear() 229 | elif ex.args[0] == SSL_ERROR_WANT_WRITE: 230 | sys.exc_clear() 231 | else: 232 | raise 233 | 234 | self.connection_alive = True 235 | _logger.debug("%s APNS connection established" % self.__class__.__name__) 236 | 237 | def _disconnect(self): 238 | if self.connection_alive: 239 | if self._socket: 240 | self._socket.close() 241 | if self._ssl: 242 | self._ssl.close() 243 | self.connection_alive = False 244 | _logger.info(" %s APNS connection closed" % self.__class__.__name__) 245 | 246 | def _connection(self): 247 | if not self._ssl or not self.connection_alive: 248 | self._connect() 249 | return self._ssl 250 | 251 | def read(self, n=None): 252 | return self._connection().read(n) 253 | 254 | def write(self, string): 255 | if self.enhanced: # nonblocking socket 256 | self._last_activity_time = time.time() 257 | _, wlist, _ = select.select([], [self._connection()], [], WAIT_WRITE_TIMEOUT_SEC) 258 | 259 | if len(wlist) > 0: 260 | length = self._connection().sendall(string) 261 | if length == 0: 262 | _logger.debug("sent length: %d" % length) #DEBUG 263 | else: 264 | _logger.warning("write socket descriptor is not ready after " + str(WAIT_WRITE_TIMEOUT_SEC)) 265 | 266 | else: # blocking socket 267 | return self._connection().write(string) 268 | 269 | 270 | class PayloadAlert(object): 271 | def __init__(self, body=None, title = None, subtitle = None, action_loc_key=None, loc_key=None, 272 | loc_args=None, launch_image=None): 273 | super(PayloadAlert, self).__init__() 274 | 275 | self.body = body 276 | self.title = title 277 | self.subtitle = subtitle 278 | self.action_loc_key = action_loc_key 279 | self.loc_key = loc_key 280 | self.loc_args = loc_args 281 | self.launch_image = launch_image 282 | 283 | def dict(self): 284 | d = {} 285 | 286 | if self.body: 287 | d['body'] = self.body 288 | if self.title: 289 | d['title'] = self.title 290 | if self.subtitle: 291 | d['subtitle'] = self.subtitle 292 | if self.action_loc_key: 293 | d['action-loc-key'] = self.action_loc_key 294 | if self.loc_key: 295 | d['loc-key'] = self.loc_key 296 | if self.loc_args: 297 | d['loc-args'] = self.loc_args 298 | if self.launch_image: 299 | d['launch-image'] = self.launch_image 300 | return d 301 | 302 | class PayloadTooLargeError(Exception): 303 | def __init__(self, payload_size): 304 | super(PayloadTooLargeError, self).__init__() 305 | self.payload_size = payload_size 306 | 307 | class Payload(object): 308 | """A class representing an APNs message payload""" 309 | def __init__(self, alert=None, badge=None, sound=None, category=None, custom=None, content_available=False, 310 | mutable_content=False): 311 | super(Payload, self).__init__() 312 | self.alert = alert 313 | self.badge = badge 314 | self.sound = sound 315 | self.category = category 316 | self.custom = custom 317 | self.content_available = content_available 318 | self.mutable_content = mutable_content 319 | self._check_size() 320 | 321 | def dict(self): 322 | """Returns the payload as a regular Python dictionary""" 323 | d = {} 324 | if self.alert: 325 | # Alert can be either a string or a PayloadAlert 326 | # object 327 | if isinstance(self.alert, PayloadAlert): 328 | d['alert'] = self.alert.dict() 329 | else: 330 | d['alert'] = self.alert 331 | if self.sound: 332 | d['sound'] = self.sound 333 | if self.badge is not None: 334 | d['badge'] = int(self.badge) 335 | if self.category: 336 | d['category'] = self.category 337 | 338 | if self.content_available: 339 | d.update({'content-available': 1}) 340 | 341 | if self.mutable_content: 342 | d.update({'mutable-content': 1}) 343 | 344 | d = { 'aps': d } 345 | if self.custom: 346 | d.update(self.custom) 347 | return d 348 | 349 | def json(self): 350 | return json.dumps(self.dict(), separators=(',',':'), ensure_ascii=False).encode('utf-8') 351 | 352 | def _check_size(self): 353 | payload_length = len(self.json()) 354 | if payload_length > MAX_PAYLOAD_LENGTH: 355 | raise PayloadTooLargeError(payload_length) 356 | 357 | def __repr__(self): 358 | attrs = ("alert", "badge", "sound", "category", "custom") 359 | args = ", ".join(["%s=%r" % (n, getattr(self, n)) for n in attrs]) 360 | return "%s(%s)" % (self.__class__.__name__, args) 361 | 362 | class Frame(object): 363 | """A class representing an APNs message frame for multiple sending""" 364 | def __init__(self): 365 | self.frame_data = bytearray() 366 | self.notification_data = list() 367 | 368 | def get_frame(self): 369 | return self.frame_data 370 | 371 | def add_item(self, token_hex, payload, identifier, expiry, priority): 372 | """Add a notification message to the frame""" 373 | item_len = 0 374 | self.frame_data.extend(b'\2' + APNs.packed_uint_big_endian(item_len)) 375 | 376 | token_bin = a2b_hex(token_hex) 377 | token_length_bin = APNs.packed_ushort_big_endian(len(token_bin)) 378 | token_item = b'\1' + token_length_bin + token_bin 379 | self.frame_data.extend(token_item) 380 | item_len += len(token_item) 381 | 382 | payload_json = payload.json() 383 | payload_length_bin = APNs.packed_ushort_big_endian(len(payload_json)) 384 | payload_item = b'\2' + payload_length_bin + payload_json 385 | self.frame_data.extend(payload_item) 386 | item_len += len(payload_item) 387 | 388 | identifier_bin = APNs.packed_uint_big_endian(identifier) 389 | identifier_length_bin = \ 390 | APNs.packed_ushort_big_endian(len(identifier_bin)) 391 | identifier_item = b'\3' + identifier_length_bin + identifier_bin 392 | self.frame_data.extend(identifier_item) 393 | item_len += len(identifier_item) 394 | 395 | expiry_bin = APNs.packed_uint_big_endian(expiry) 396 | expiry_length_bin = APNs.packed_ushort_big_endian(len(expiry_bin)) 397 | expiry_item = b'\4' + expiry_length_bin + expiry_bin 398 | self.frame_data.extend(expiry_item) 399 | item_len += len(expiry_item) 400 | 401 | priority_bin = APNs.packed_uchar(priority) 402 | priority_length_bin = APNs.packed_ushort_big_endian(len(priority_bin)) 403 | priority_item = b'\5' + priority_length_bin + priority_bin 404 | self.frame_data.extend(priority_item) 405 | item_len += len(priority_item) 406 | 407 | self.frame_data[-item_len-4:-item_len] = APNs.packed_uint_big_endian(item_len) 408 | 409 | self.notification_data.append({'token':token_hex, 'payload':payload, 'identifier':identifier, 'expiry':expiry, "priority":priority}) 410 | 411 | def get_notifications(self, gateway_connection): 412 | notifications = list({'id': x['identifier'], 'message':gateway_connection._get_enhanced_notification(x['token'], x['payload'],x['identifier'], x['expiry'])} for x in self.notification_data) 413 | return notifications 414 | 415 | def __str__(self): 416 | """Get the frame buffer""" 417 | return str(self.frame_data) 418 | 419 | class FeedbackConnection(APNsConnection): 420 | """ 421 | A class representing a connection to the APNs Feedback server 422 | """ 423 | def __init__(self, use_sandbox=False, **kwargs): 424 | super(FeedbackConnection, self).__init__(**kwargs) 425 | self.server = ( 426 | 'feedback.push.apple.com', 427 | 'feedback.sandbox.push.apple.com')[use_sandbox] 428 | self.port = 2196 429 | 430 | def _chunks(self): 431 | BUF_SIZE = 4096 432 | while 1: 433 | data = self.read(BUF_SIZE) 434 | yield data 435 | if not data: 436 | break 437 | 438 | def items(self): 439 | """ 440 | A generator that yields (token_hex, fail_time) pairs retrieved from 441 | the APNs feedback server 442 | """ 443 | buff = b'' 444 | for chunk in self._chunks(): 445 | buff += chunk 446 | 447 | # Quit if there's no more data to read 448 | if not buff: 449 | break 450 | 451 | # Sanity check: after a socket read we should always have at least 452 | # 6 bytes in the buffer 453 | if len(buff) < 6: 454 | break 455 | 456 | while len(buff) > 6: 457 | token_length = APNs.unpacked_ushort_big_endian(buff[4:6]) 458 | bytes_to_read = 6 + token_length 459 | if len(buff) >= bytes_to_read: 460 | fail_time_unix = APNs.unpacked_uint_big_endian(buff[0:4]) 461 | fail_time = datetime.utcfromtimestamp(fail_time_unix) 462 | token = b2a_hex(buff[6:bytes_to_read]) 463 | 464 | yield (token, fail_time) 465 | 466 | # Remove data for current token from buffer 467 | buff = buff[bytes_to_read:] 468 | else: 469 | # break out of inner while loop - i.e. go and fetch 470 | # some more data and append to buffer 471 | break 472 | 473 | class GatewayConnection(APNsConnection): 474 | """ 475 | A class that represents a connection to the APNs gateway server 476 | """ 477 | 478 | def __init__(self, use_sandbox=False, **kwargs): 479 | super(GatewayConnection, self).__init__(**kwargs) 480 | self.server = ( 481 | 'gateway.push.apple.com', 482 | 'gateway.sandbox.push.apple.com')[use_sandbox] 483 | self.port = 2195 484 | if self.enhanced == True: #start error-response monitoring thread 485 | self._last_activity_time = time.time() 486 | 487 | self._send_lock = threading.RLock() 488 | self._error_response_handler_worker = None 489 | self._response_listener = None 490 | 491 | self._sent_notifications = collections.deque(maxlen=SENT_BUFFER_QTY) 492 | 493 | def _init_error_response_handler_worker(self): 494 | self._send_lock = threading.RLock() 495 | self._error_response_handler_worker = self.ErrorResponseHandlerWorker(apns_connection=self) 496 | self._error_response_handler_worker.start() 497 | _logger.debug("initialized error-response handler worker") 498 | 499 | def _get_notification(self, token_hex, payload): 500 | """ 501 | Takes a token as a hex string and a payload as a Python dict and sends 502 | the notification 503 | """ 504 | token_bin = a2b_hex(token_hex) 505 | token_length_bin = APNs.packed_ushort_big_endian(len(token_bin)) 506 | payload_json = payload.json() 507 | payload_length_bin = APNs.packed_ushort_big_endian(len(payload_json)) 508 | 509 | zero_byte = '\0' 510 | if sys.version_info[0] != 2: 511 | zero_byte = bytes(zero_byte, 'utf-8') 512 | notification = (zero_byte + token_length_bin + token_bin 513 | + payload_length_bin + payload_json) 514 | 515 | return notification 516 | 517 | def _get_enhanced_notification(self, token_hex, payload, identifier, expiry): 518 | """ 519 | form notification data in an enhanced format 520 | """ 521 | token = a2b_hex(token_hex) 522 | payload = payload.json() 523 | fmt = ENHANCED_NOTIFICATION_FORMAT % len(payload) 524 | notification = pack(fmt, ENHANCED_NOTIFICATION_COMMAND, identifier, expiry, 525 | TOKEN_LENGTH, token, len(payload), payload) 526 | return notification 527 | 528 | def send_notification(self, token_hex, payload, identifier=0, expiry=0): 529 | """ 530 | in enhanced mode, send_notification may return error response from APNs if any 531 | """ 532 | if self.enhanced: 533 | self._last_activity_time = time.time() 534 | message = self._get_enhanced_notification(token_hex, payload, 535 | identifier, expiry) 536 | 537 | for i in range(WRITE_RETRY): 538 | try: 539 | with self._send_lock: 540 | self._make_sure_error_response_handler_worker_alive() 541 | self.write(message) 542 | self._sent_notifications.append(dict({'id': identifier, 'message': message})) 543 | break 544 | except socket_error as e: 545 | delay = 10 + (i * 2) 546 | _logger.exception("sending notification with id:" + str(identifier) + 547 | " to APNS failed: " + str(type(e)) + ": " + str(e) + 548 | " in " + str(i+1) + "th attempt, will wait " + str(delay) + " secs for next action") 549 | time.sleep(delay) # wait potential error-response to be read 550 | 551 | else: 552 | self.write(self._get_notification(token_hex, payload)) 553 | 554 | def _make_sure_error_response_handler_worker_alive(self): 555 | if (not self._error_response_handler_worker 556 | or not self._error_response_handler_worker.is_alive()): 557 | self._init_error_response_handler_worker() 558 | TIMEOUT_SEC = 10 559 | for _ in range(TIMEOUT_SEC): 560 | if self._error_response_handler_worker.is_alive(): 561 | _logger.debug("error response handler worker is running") 562 | return 563 | time.sleep(1) 564 | _logger.warning("error response handler worker is not started after %s secs" % TIMEOUT_SEC) 565 | 566 | def send_notification_multiple(self, frame): 567 | self._sent_notifications += frame.get_notifications(self) 568 | return self.write(frame.get_frame()) 569 | 570 | def register_response_listener(self, response_listener): 571 | self._response_listener = response_listener 572 | 573 | def force_close(self): 574 | if self._error_response_handler_worker: 575 | self._error_response_handler_worker.close() 576 | 577 | def _is_idle_timeout(self): 578 | TIMEOUT_IDLE = 30 579 | return (time.time() - self._last_activity_time) >= TIMEOUT_IDLE 580 | 581 | class ErrorResponseHandlerWorker(threading.Thread): 582 | def __init__(self, apns_connection): 583 | threading.Thread.__init__(self, name=self.__class__.__name__) 584 | self._apns_connection = apns_connection 585 | self._close_signal = False 586 | 587 | def close(self): 588 | self._close_signal = True 589 | 590 | def run(self): 591 | while True: 592 | if self._close_signal: 593 | _logger.debug("received close thread signal") 594 | break 595 | 596 | if self._apns_connection._is_idle_timeout(): 597 | idled_time = (time.time() - self._apns_connection._last_activity_time) 598 | _logger.debug("connection idle after %d secs" % idled_time) 599 | break 600 | 601 | if not self._apns_connection.connection_alive: 602 | time.sleep(1) 603 | continue 604 | 605 | try: 606 | rlist, _, _ = select.select([self._apns_connection._connection()], [], [], WAIT_READ_TIMEOUT_SEC) 607 | 608 | if len(rlist) > 0: # there's some data from APNs 609 | with self._apns_connection._send_lock: 610 | buff = self._apns_connection.read(ERROR_RESPONSE_LENGTH) 611 | if len(buff) == ERROR_RESPONSE_LENGTH: 612 | command, status, identifier = unpack(ERROR_RESPONSE_FORMAT, buff) 613 | if 8 == command: # there is error response from APNS 614 | error_response = (status, identifier) 615 | if self._apns_connection._response_listener: 616 | self._apns_connection._response_listener(Util.convert_error_response_to_dict(error_response)) 617 | _logger.info("got error-response from APNS:" + str(error_response)) 618 | self._apns_connection._disconnect() 619 | self._resend_notifications_by_id(identifier) 620 | if len(buff) == 0: 621 | _logger.warning("read socket got 0 bytes data") #DEBUG 622 | self._apns_connection._disconnect() 623 | 624 | except socket_error as e: # APNS close connection arbitrarily 625 | _logger.exception("exception occur when reading APNS error-response: " + str(type(e)) + ": " + str(e)) #DEBUG 626 | self._apns_connection._disconnect() 627 | continue 628 | 629 | time.sleep(0.1) #avoid crazy loop if something bad happened. e.g. using invalid certificate 630 | 631 | self._apns_connection._disconnect() 632 | _logger.debug("error-response handler worker closed") #DEBUG 633 | 634 | def _resend_notifications_by_id(self, failed_identifier): 635 | fail_idx = Util.getListIndexFromID(self._apns_connection._sent_notifications, failed_identifier) 636 | #pop-out success notifications till failed one 637 | self._resend_notification_by_range(fail_idx+1, len(self._apns_connection._sent_notifications)) 638 | return 639 | 640 | def _resend_notification_by_range(self, start_idx, end_idx): 641 | self._apns_connection._sent_notifications = collections.deque(itertools.islice(self._apns_connection._sent_notifications, start_idx, end_idx)) 642 | _logger.info("resending %s notifications to APNS" % len(self._apns_connection._sent_notifications)) #DEBUG 643 | for sent_notification in self._apns_connection._sent_notifications: 644 | _logger.debug("resending notification with id:" + str(sent_notification['id']) + " to APNS") #DEBUG 645 | try: 646 | self._apns_connection.write(sent_notification['message']) 647 | except socket_error as e: 648 | _logger.exception("resending notification with id:" + str(sent_notification['id']) + " failed: " + str(type(e)) + ": " + str(e)) #DEBUG 649 | break 650 | time.sleep(DELAY_RESEND_SEC) #DEBUG 651 | 652 | class Util(object): 653 | @classmethod 654 | def getListIndexFromID(this_class, the_list, identifier): 655 | return next(index for (index, d) in enumerate(the_list) 656 | if d['id'] == identifier) 657 | @classmethod 658 | def convert_error_response_to_dict(this_class, error_response_tuple): 659 | return {ER_STATUS: error_response_tuple[0], ER_IDENTIFER: error_response_tuple[1]} 660 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | author = 'David Jacobs', 5 | author_email = 'david@29.io', 6 | description = 'A python library for interacting with the Apple Push Notification Service', 7 | download_url = 'https://github.com/djacobs/PyAPNs', 8 | license = 'unlicense.org', 9 | name = 'apns', 10 | py_modules = ['apns'], 11 | scripts = ['apns-send'], 12 | url = 'http://29.io/', 13 | version = '2.0.1', 14 | ) 15 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | from apns import * 4 | from binascii import a2b_hex 5 | from random import random 6 | 7 | import hashlib 8 | import os 9 | import time 10 | import unittest 11 | 12 | TEST_CERTIFICATE = "certificate.pem" # replace with path to test certificate 13 | 14 | NUM_MOCK_TOKENS = 10 15 | mock_tokens = [] 16 | for i in range(0, NUM_MOCK_TOKENS): 17 | mock_tokens.append(bytes(hashlib.sha256(("%.12f" % random()).encode("utf-8")).hexdigest().encode("utf-8"))) 18 | 19 | def mock_chunks_generator(): 20 | BUF_SIZE = 64 21 | # Create fake data feed 22 | data = b'' 23 | 24 | for t in mock_tokens: 25 | token_bin = a2b_hex(t) 26 | token_length = len(token_bin) 27 | 28 | data += APNs.packed_uint_big_endian(int(time.time())) 29 | data += APNs.packed_ushort_big_endian(token_length) 30 | data += token_bin 31 | 32 | while data: 33 | yield data[0:BUF_SIZE] 34 | data = data[BUF_SIZE:] 35 | 36 | 37 | class TestAPNs(unittest.TestCase): 38 | """Unit tests for PyAPNs""" 39 | 40 | def setUp(self): 41 | """docstring for setUp""" 42 | pass 43 | 44 | def tearDown(self): 45 | """docstring for tearDown""" 46 | pass 47 | 48 | def testConfigs(self): 49 | apns_test = APNs(use_sandbox=True) 50 | apns_prod = APNs(use_sandbox=False) 51 | 52 | self.assertEqual(apns_test.gateway_server.port, 2195) 53 | self.assertEqual(apns_test.gateway_server.server, 54 | 'gateway.sandbox.push.apple.com') 55 | self.assertEqual(apns_test.feedback_server.port, 2196) 56 | self.assertEqual(apns_test.feedback_server.server, 57 | 'feedback.sandbox.push.apple.com') 58 | 59 | self.assertEqual(apns_prod.gateway_server.port, 2195) 60 | self.assertEqual(apns_prod.gateway_server.server, 61 | 'gateway.push.apple.com') 62 | self.assertEqual(apns_prod.feedback_server.port, 2196) 63 | self.assertEqual(apns_prod.feedback_server.server, 64 | 'feedback.push.apple.com') 65 | 66 | def testGatewayServer(self): 67 | pem_file = TEST_CERTIFICATE 68 | apns = APNs(use_sandbox=True, cert_file=pem_file, key_file=pem_file) 69 | gateway_server = apns.gateway_server 70 | 71 | self.assertEqual(gateway_server.cert_file, apns.cert_file) 72 | self.assertEqual(gateway_server.key_file, apns.key_file) 73 | 74 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c' 75 | payload = Payload( 76 | alert = "Hello World!", 77 | sound = "default", 78 | badge = 4 79 | ) 80 | notification = gateway_server._get_notification(token_hex, payload) 81 | 82 | expected_length = ( 83 | 1 # leading null byte 84 | + 2 # length of token as a packed short 85 | + len(token_hex) / 2 # length of token as binary string 86 | + 2 # length of payload as a packed short 87 | + len(payload.json()) # length of JSON-formatted payload 88 | ) 89 | 90 | self.assertEqual(len(notification), expected_length) 91 | self.assertEqual(notification[0:1], b'\0') 92 | 93 | def testFeedbackServer(self): 94 | pem_file = TEST_CERTIFICATE 95 | apns = APNs(use_sandbox=True, cert_file=pem_file, key_file=pem_file) 96 | feedback_server = apns.feedback_server 97 | 98 | self.assertEqual(feedback_server.cert_file, apns.cert_file) 99 | self.assertEqual(feedback_server.key_file, apns.key_file) 100 | 101 | # Overwrite _chunks() to call a mock chunk generator 102 | feedback_server._chunks = mock_chunks_generator 103 | 104 | i = 0; 105 | for (token_hex, fail_time) in list(feedback_server.items()): 106 | self.assertEqual(token_hex, mock_tokens[i]) 107 | i += 1 108 | self.assertEqual(i, NUM_MOCK_TOKENS) 109 | 110 | def testPayloadAlert(self): 111 | pa = PayloadAlert('foo') 112 | d = pa.dict() 113 | self.assertEqual(d['body'], 'foo') 114 | self.assertFalse('action-loc-key' in d) 115 | self.assertFalse('loc-key' in d) 116 | self.assertFalse('loc-args' in d) 117 | self.assertFalse('launch-image' in d) 118 | 119 | pa = PayloadAlert('foo', action_loc_key='bar', loc_key='wibble', 120 | loc_args=['king','kong'], launch_image='wobble') 121 | d = pa.dict() 122 | self.assertEqual(d['body'], 'foo') 123 | self.assertEqual(d['action-loc-key'], 'bar') 124 | self.assertEqual(d['loc-key'], 'wibble') 125 | self.assertEqual(d['loc-args'], ['king','kong']) 126 | self.assertEqual(d['launch-image'], 'wobble') 127 | 128 | pa = PayloadAlert(loc_key='wibble') 129 | d = pa.dict() 130 | self.assertTrue('body' not in d) 131 | self.assertEqual(d['loc-key'], 'wibble') 132 | 133 | def testPayload(self): 134 | # Payload with just alert 135 | p = Payload(alert=PayloadAlert('foo')) 136 | d = p.dict() 137 | self.assertTrue('alert' in d['aps']) 138 | self.assertTrue('sound' not in d['aps']) 139 | self.assertTrue('badge' not in d['aps']) 140 | 141 | # Payload with just sound 142 | p = Payload(sound="foo") 143 | d = p.dict() 144 | self.assertTrue('sound' in d['aps']) 145 | self.assertTrue('alert' not in d['aps']) 146 | self.assertTrue('badge' not in d['aps']) 147 | 148 | # Payload with just badge 149 | p = Payload(badge=1) 150 | d = p.dict() 151 | self.assertTrue('badge' in d['aps']) 152 | self.assertTrue('alert' not in d['aps']) 153 | self.assertTrue('sound' not in d['aps']) 154 | 155 | # Payload with just badge removal 156 | p = Payload(badge=0) 157 | d = p.dict() 158 | self.assertTrue('badge' in d['aps']) 159 | self.assertTrue('alert' not in d['aps']) 160 | self.assertTrue('sound' not in d['aps']) 161 | 162 | # Test plain string alerts 163 | alert_str = 'foobar' 164 | p = Payload(alert=alert_str) 165 | d = p.dict() 166 | self.assertEqual(d['aps']['alert'], alert_str) 167 | self.assertTrue('sound' not in d['aps']) 168 | self.assertTrue('badge' not in d['aps']) 169 | 170 | # Test custom payload 171 | alert_str = 'foobar' 172 | custom_dict = {'foo': 'bar'} 173 | p = Payload(alert=alert_str, custom=custom_dict) 174 | d = p.dict() 175 | self.assertEqual(d, {'foo': 'bar', 'aps': {'alert': 'foobar'}}) 176 | 177 | def testFrame(self): 178 | identifier = 1 179 | expiry = 3600 180 | token_hex = 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c' 181 | payload = Payload( 182 | alert = "Hello World!", 183 | sound = "default", 184 | badge = 4 185 | ) 186 | priority = 10 187 | 188 | frame = Frame() 189 | frame.add_item(token_hex, payload, identifier, expiry, priority) 190 | 191 | f1 = bytearray(b'\x02\x00\x00\x00t\x01\x00 \xb5\xbb\x9d\x80\x14\xa0\xf9\xb1\xd6\x1e!\xe7\x96\xd7\x8d\xcc\xdf\x13R\xf2<\xd3(\x12\xf4\x85\x0b\x87\x8a\xe4\x94L\x02\x00<{"aps":{"sound":"default","badge":4,"alert":"Hello World!"}}\x03\x00\x04\x00\x00\x00\x01\x04\x00\x04\x00\x00\x0e\x10\x05\x00\x01\n') 192 | f2 = bytearray(b'\x02\x00\x00\x00t\x01\x00 \xb5\xbb\x9d\x80\x14\xa0\xf9\xb1\xd6\x1e!\xe7\x96\xd7\x8d\xcc\xdf\x13R\xf2<\xd3(\x12\xf4\x85\x0b\x87\x8a\xe4\x94L\x02\x00<{"aps":{"sound":"default","alert":"Hello World!","badge":4}}\x03\x00\x04\x00\x00\x00\x01\x04\x00\x04\x00\x00\x0e\x10\x05\x00\x01\n') 193 | self.assertTrue(f1 == frame.get_frame() or f2 == frame.get_frame()) 194 | 195 | def testPayloadTooLargeError(self): 196 | # The maximum size of the JSON payload is MAX_PAYLOAD_LENGTH 197 | # bytes. First determine how many bytes this allows us in the 198 | # raw payload (i.e. before JSON serialisation) 199 | json_overhead_bytes = len(Payload('.').json()) - 1 200 | max_raw_payload_bytes = MAX_PAYLOAD_LENGTH - json_overhead_bytes 201 | 202 | # Test ascii characters payload 203 | Payload('.' * max_raw_payload_bytes) 204 | self.assertRaises(PayloadTooLargeError, Payload, 205 | '.' * (max_raw_payload_bytes + 1)) 206 | 207 | # Test unicode 2-byte characters payload 208 | Payload(u'\u0100' * int(max_raw_payload_bytes / 2)) 209 | self.assertRaises(PayloadTooLargeError, Payload, 210 | u'\u0100' * (int(max_raw_payload_bytes / 2) + 1)) 211 | 212 | if __name__ == '__main__': 213 | unittest.main() 214 | --------------------------------------------------------------------------------