├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── apns ├── __init__.py ├── commands.py ├── errorresponse.py ├── feedback.py ├── feedbackclient.py ├── gatewayclient.py ├── listenable.py ├── notification.py ├── tests │ ├── test_errorresponse.py │ ├── test_feedback.py │ ├── test_feedbackclient.py │ ├── test_gatewayclient.py │ ├── test_listenable.py │ ├── test_notification.py │ └── test_utils.py └── utils.py ├── release.sh ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # Vim swap file 60 | *.sw[a-z] 61 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install tox 4 | script: 5 | - tox 6 | env: 7 | - TOXENV=py27 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Opera Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twisted client for APNs 2 | [![Build status](https://travis-ci.org/operasoftware/twisted-apns.svg)](https://travis-ci.org/operasoftware/twisted-apns) 3 | [![Version](https://img.shields.io/pypi/v/twisted-apns.svg)](https://pypi.python.org/pypi/twisted-apns) 4 | [![Code Climate](https://codeclimate.com/github/operasoftware/twisted-apns/badges/gpa.svg)](https://codeclimate.com/github/operasoftware/twisted-apns) 5 | 6 | ## Overview 7 | *Twisted-APNs* is an implementation of provider-side client for Apple Push Notification Service, based on [official iOS documentation](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html). It uses [Twisted networking engine](https://twistedmatrix.com). 8 | 9 | ## Features 10 | * Sending notifications through gateway service 11 | * Querying feedback service for failed remote notifications 12 | 13 | ## Requirements 14 | * Python>=2.7 15 | * Twisted (version 15.0.0 known to work) 16 | * pyOpenSSL 17 | 18 | ## Download 19 | * [GitHub](https://github.com/operasoftware/twisted-apns) 20 | 21 | ## Installation 22 | You can install it easily from PyPi by single command: 23 | ``` 24 | pip install twisted-apns 25 | ``` 26 | or clone source code and run: 27 | ``` 28 | python setup.py install 29 | ``` 30 | 31 | ## Usage examples 32 | 33 | ### Sending a notification 34 | 35 | First, do the necessary imports and set up logging for debug purposes: 36 | ```python 37 | import logging 38 | 39 | from apns.gatewayclient import GatewayClientFactory 40 | from apns.notification import Notification 41 | from twisted.internet import reactor 42 | 43 | 44 | logger = logging.getLogger() 45 | logger.setLevel(logging.DEBUG) 46 | logger.addHandler(logging.StreamHandler()) 47 | ``` 48 | 49 | Then create an instance of gateway client factory, specifying intended endpoint (`pub` for production or `dev` for development purposes), and setting a path to your provider certificate: 50 | ```python 51 | # Make sure /apn-dev.pem exists or pass valid path. 52 | factory = GatewayClientFactory('dev', '/apn-dev.pem') 53 | reactor.connectSSL(factory.hostname, 54 | factory.port, 55 | factory, 56 | factory.certificate.options()) 57 | ``` 58 | The code below sends a sample notification with JSON-encoded payload to device identified with supplied `token` (in hex): 59 | ```python 60 | def send(): 61 | token = '00' # Set to something valid. 62 | payload = {'aps': {'alert': "What's up?", 63 | 'sound': 'default', 64 | 'badge': 3}} 65 | notification = Notification(token=token, 66 | expire=Notification.EXPIRE_IMMEDIATELY, 67 | payload=payload) 68 | factory.send(notification) 69 | 70 | reactor.callLater(1, send) 71 | reactor.run() 72 | ``` 73 | 74 | If `token` is valid console outputs: 75 | ``` 76 | Gateway connection made: gateway.push.apple.com:2195 77 | Gateway send notification 78 | ``` 79 | 80 | 81 | If not (like `00`) error is returned: 82 | ``` 83 | Gateway connection made: gateway.sandbox.push.apple.com:2195 84 | Gateway send notification 85 | Gateway error received: 86 | Gateway connection lost: Connection was closed cleanly. 87 | Gateway connection made: gateway.sandbox.push.apple.com:2195 88 | ``` 89 | 90 | ### Querying list of invalidated tokens 91 | 92 | The following code connects to the feedback service and prints tokens which should not be used anymore: 93 | ```python 94 | import logging 95 | 96 | from apns.feedbackclient import FeedbackClientFactory 97 | from apns.feedback import Feedback 98 | from twisted.internet import reactor 99 | 100 | logger = logging.getLogger() 101 | logger.setLevel(logging.DEBUG) 102 | logger.addHandler(logging.StreamHandler()) 103 | 104 | # Make sure /apn-dev.pem exists or pass valid path. 105 | factory = FeedbackClientFactory('dev', '/apn-dev.pem') 106 | reactor.connectSSL(factory.hostname, 107 | factory.port, 108 | factory, 109 | factory.certificate.options()) 110 | 111 | def onFeedbacks(feedbacks): 112 | for f in feedbacks: 113 | print "It would be better to stop sending notifications to", f.token 114 | 115 | factory.listen(FeedbackClientFactory.EVENT_FEEDBACKS_RECEIVED, onFeedbacks) 116 | 117 | reactor.run() 118 | ``` 119 | 120 | ## Contributing 121 | You are highly encouraged to participate in the development, simply use GitHub's fork/pull request system. 122 | -------------------------------------------------------------------------------- /apns/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/operasoftware/twisted-apns/c7bd460100067e0c96c440ac0f5516485ac7313f/apns/__init__.py -------------------------------------------------------------------------------- /apns/commands.py: -------------------------------------------------------------------------------- 1 | """Definition of command codes used in communication with APNs.""" 2 | 3 | NOTIFICATION = 2 4 | ERROR_RESPONSE = 8 5 | -------------------------------------------------------------------------------- /apns/errorresponse.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | from apns.commands import ERROR_RESPONSE 4 | 5 | 6 | class ErrorResponseError(Exception): 7 | """To be thrown upon failures on error response processing.""" 8 | pass 9 | 10 | 11 | class ErrorResponseInvalidCommandError(ErrorResponseError): 12 | """ 13 | Thrown while unpacking an error response, if the command field contains 14 | invalid value. 15 | """ 16 | pass 17 | 18 | 19 | class ErrorResponseInvalidCodeError(ErrorResponseError): 20 | """ 21 | Thrown while unpacking an error response, if the status code field contains 22 | invalid value. 23 | """ 24 | pass 25 | 26 | 27 | class ErrorResponse(object): 28 | """ 29 | A representation of the structure of an error response, as defined in the 30 | iOS documentation. 31 | """ 32 | CODE_OK = 0 33 | CODE_PROCESSING_ERROR = 1 34 | CODE_MISSING_TOKEN = 2 35 | CODE_MISSING_TOPIC = 3 36 | CODE_MISSING_PAYLOAD = 4 37 | CODE_INVALID_TOKEN_SIZE = 5 38 | CODE_INVALID_TOPIC_SIZE = 6 39 | CODE_INVALID_PAYLOAD_SIZE = 7 40 | CODE_INVALID_TOKEN = 8 41 | CODE_SHUTDOWN = 10 42 | CODE_UNKNOWN = 255 43 | 44 | CODES = { 45 | CODE_OK: 'No errors encountered', 46 | CODE_PROCESSING_ERROR: 'Processing error', 47 | CODE_MISSING_TOKEN: 'Missing token', 48 | CODE_MISSING_TOPIC: 'Missing topic', 49 | CODE_MISSING_PAYLOAD: 'Missing payload', 50 | CODE_INVALID_TOKEN_SIZE: 'Invalid token size', 51 | CODE_INVALID_TOPIC_SIZE: 'Invalid topic size', 52 | CODE_INVALID_PAYLOAD_SIZE: 'Invalid payload size', 53 | CODE_INVALID_TOKEN: 'Invalid token', 54 | CODE_SHUTDOWN: 'Shutdown', 55 | CODE_UNKNOWN: 'Unknown' 56 | } 57 | 58 | FORMAT = '>BBI' 59 | COMMAND = ERROR_RESPONSE 60 | 61 | def __init__(self): 62 | self.code = self.name = self.identifier = None 63 | 64 | def __str__(self): 65 | return '' % self.name 66 | 67 | def from_binary_string(self, stream): 68 | """Unpack the error response from a stream.""" 69 | command, code, identifier = struct.unpack(self.FORMAT, stream) 70 | 71 | if command != self.COMMAND: 72 | raise ErrorResponseInvalidCommandError() 73 | 74 | if code not in self.CODES: 75 | raise ErrorResponseInvalidCodeError() 76 | 77 | self.code = code 78 | self.name = self.CODES[code] 79 | self.identifier = identifier 80 | 81 | def to_binary_string(self, code, identifier): 82 | """Pack the error response to binary string and return it.""" 83 | return struct.pack(self.FORMAT, self.COMMAND, code, identifier) 84 | -------------------------------------------------------------------------------- /apns/feedback.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import binascii 3 | import struct 4 | 5 | from apns.utils import datetime_to_timestamp 6 | 7 | 8 | class Feedback(object): 9 | """ 10 | A representation of the structure of a feedback response, as defined in the 11 | iOS documentation. 12 | """ 13 | FORMAT_PREFIX = '>IH' 14 | 15 | def __init__(self, when=None, token=None): 16 | self.when = when 17 | self.token = token 18 | 19 | def __str__(self): 20 | return '' % (self.token, self.when) 21 | 22 | 23 | @classmethod 24 | def from_binary_string(cls, stream): 25 | """ 26 | Read feedback information from the stream and unpack it. 27 | :param stream: A stream of feedback data from APN. Can contain multiple 28 | feedback tuples, as defined in the feedback service protocol. 29 | :return A list containing all unpacked feedbacks. 30 | """ 31 | offset = 0 32 | length = len(stream) 33 | feedbacks = [] 34 | 35 | while offset < length: 36 | timestamp, token_length = struct.unpack(cls.FORMAT_PREFIX, 37 | stream[offset:offset+6]) 38 | when = datetime.fromtimestamp(timestamp) 39 | offset += 6 40 | token = struct.unpack('>{0}s'.format(token_length), 41 | stream[offset:offset+token_length])[0] 42 | token = binascii.hexlify(token) 43 | offset += token_length 44 | feedbacks.append(cls(when, token)) 45 | 46 | return feedbacks 47 | 48 | def to_binary_string(self): 49 | """Pack the feedback to binary form and return it as string.""" 50 | timestamp = datetime_to_timestamp(self.when) 51 | token = binascii.unhexlify(self.token) 52 | return struct.pack(self.FORMAT_PREFIX + '{0}s'.format(len(token)), 53 | timestamp, len(token), token) 54 | 55 | 56 | -------------------------------------------------------------------------------- /apns/feedbackclient.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from twisted.internet import defer, ssl 4 | from twisted.internet.protocol import Protocol, ReconnectingClientFactory 5 | 6 | from apns.feedback import Feedback 7 | from apns.listenable import Listenable 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class FeedbackClient(Protocol): 14 | """ 15 | Implements client-side of APN feedback service protocol. Should be spawned 16 | by FeedbackClientFactory and generally should not be used standalone. 17 | """ 18 | def connectionMade(self): 19 | logger.debug('Feedback connection made: %s:%d', self.factory.hostname, 20 | self.factory.port) 21 | 22 | @defer.inlineCallbacks 23 | def dataReceived(self, data): 24 | feedbacks = Feedback.from_binary_string(data) 25 | yield self.factory.feedbacksReceived(feedbacks) 26 | 27 | 28 | class FeedbackClientFactory(ReconnectingClientFactory, Listenable): 29 | """ 30 | Allows connecting to the APN feedback service and receiving feedback 31 | information. To process received feedbacks in your code, add a callback to 32 | EVENT_FEEDBACKS_RECEIVED. 33 | """ 34 | protocol = FeedbackClient 35 | maxDelay = 600 36 | ENDPOINTS = { 37 | 'pub': ('feedback.push.apple.com', 2196), 38 | 'dev': ('feedback.sandbox.push.apple.com', 2196) 39 | } 40 | EVENT_FEEDBACKS_RECEIVED = 'feedbacks received' 41 | 42 | def __init__(self, endpoint, pem): 43 | """ 44 | Init an instance of FeedbackClientFactory. 45 | :param endpoint: Either 'pub' for production or 'dev' for development. 46 | :param pem: Path to a provider private certificate file. 47 | """ 48 | Listenable.__init__(self) 49 | self.hostname, self.port = self.ENDPOINTS[endpoint] 50 | self.client = None 51 | 52 | with open(pem) as f: 53 | self.certificate = ssl.PrivateCertificate.loadPEM(f.read()) 54 | 55 | @defer.inlineCallbacks 56 | def feedbacksReceived(self, feedbacks): 57 | logger.debug('Feedbacks received: %s', feedbacks) 58 | yield self.dispatchEvent(self.EVENT_FEEDBACKS_RECEIVED, feedbacks) 59 | 60 | def clientConnectionFailed(self, connector, reason): 61 | logger.debug('Feedback connection failed: %s', 62 | reason.getErrorMessage()) 63 | return ReconnectingClientFactory.clientConnectionFailed(self, 64 | connector, 65 | reason) 66 | 67 | def clientConnectionLost(self, connector, reason): 68 | logger.debug('Feedback connection lost: %s', 69 | reason.getErrorMessage()) 70 | return ReconnectingClientFactory.clientConnectionLost(self, 71 | connector, 72 | reason) 73 | -------------------------------------------------------------------------------- /apns/gatewayclient.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from twisted.internet import defer, ssl 4 | from twisted.internet.protocol import Protocol, ReconnectingClientFactory 5 | 6 | from apns.errorresponse import ErrorResponse 7 | from apns.listenable import Listenable 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class GatewayClientError(Exception): 14 | """To be thrown upon failures on communication with APN gateway.""" 15 | pass 16 | 17 | 18 | class GatewayClientNotSetError(GatewayClientError): 19 | """ 20 | Thrown when attempted to send a notification while connection is not 21 | established. 22 | """ 23 | pass 24 | 25 | 26 | class GatewayClient(Protocol): 27 | """ 28 | Implements client-side of APN gateway protocol. Should be spawned by 29 | GatewayClientFactory and generally should not be used standalone. 30 | """ 31 | 32 | @defer.inlineCallbacks 33 | def connectionMade(self): 34 | logger.debug('Gateway connection made: %s:%d', self.factory.hostname, 35 | self.factory.port) 36 | yield self.factory.connectionMade(self) 37 | 38 | @defer.inlineCallbacks 39 | def send(self, notification): 40 | stream = notification.to_binary_string() 41 | yield self.transport.write(stream) 42 | 43 | @defer.inlineCallbacks 44 | def dataReceived(self, data): 45 | error = ErrorResponse() 46 | error.from_binary_string(data) 47 | yield self.factory.errorReceived(error) 48 | 49 | 50 | class GatewayClientFactory(ReconnectingClientFactory, Listenable): 51 | """Allows connecting to the APN gateway and sending notifications.""" 52 | protocol = GatewayClient 53 | maxDelay = 10 54 | ENDPOINTS = { 55 | 'pub': ('gateway.push.apple.com', 2195), 56 | 'dev': ('gateway.sandbox.push.apple.com', 2195) 57 | } 58 | EVENT_ERROR_RECEIVED = 'error received' 59 | EVENT_CONNECTION_MADE = 'connection made' 60 | EVENT_CONNECTION_LOST = 'connection lost' 61 | 62 | def __init__(self, endpoint, pem): 63 | """ 64 | Init an instance of GatewayClientFactory. 65 | :param endpoint: Either 'pub' for production or 'dev' for development. 66 | :param pem: Path to a provider private certificate file. 67 | """ 68 | Listenable.__init__(self) 69 | self.hostname, self.port = self.ENDPOINTS[endpoint] 70 | self.client = None 71 | 72 | with open(pem) as f: 73 | self.certificate = ssl.PrivateCertificate.loadPEM(f.read()) 74 | 75 | @defer.inlineCallbacks 76 | def connectionMade(self, client): 77 | self.client = client 78 | yield self.dispatchEvent(self.EVENT_CONNECTION_MADE) 79 | 80 | @defer.inlineCallbacks 81 | def _onConnectionLost(self): 82 | self.client = None 83 | yield self.dispatchEvent(self.EVENT_CONNECTION_LOST) 84 | 85 | @defer.inlineCallbacks 86 | def errorReceived(self, error): 87 | logger.debug('Gateway error received: %s', error) 88 | yield self.dispatchEvent(self.EVENT_ERROR_RECEIVED, error) 89 | 90 | @defer.inlineCallbacks 91 | def clientConnectionFailed(self, connector, reason): 92 | logger.debug('Gateway connection failed: %s', 93 | reason.getErrorMessage()) 94 | yield self._onConnectionLost() 95 | yield ReconnectingClientFactory.clientConnectionFailed(self, 96 | connector, 97 | reason) 98 | @defer.inlineCallbacks 99 | def clientConnectionLost(self, connector, reason): 100 | logger.debug('Gateway connection lost: %s', 101 | reason.getErrorMessage()) 102 | yield self._onConnectionLost() 103 | yield ReconnectingClientFactory.clientConnectionLost(self, 104 | connector, 105 | reason) 106 | 107 | @property 108 | def connected(self): 109 | """Return True if connection with APN is established.""" 110 | return self.client is not None 111 | 112 | @defer.inlineCallbacks 113 | def send(self, notification): 114 | """Send prepared notification to the APN.""" 115 | logger.debug('Gateway send notification') 116 | 117 | if self.client is None: 118 | raise GatewayClientNotSetError() 119 | 120 | yield self.client.send(notification) 121 | -------------------------------------------------------------------------------- /apns/listenable.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from twisted.internet import defer 4 | 5 | 6 | class Listenable(object): 7 | """Implements basic listener/observer model for derivative classes.""" 8 | 9 | def __init__(self): 10 | self.listeners = defaultdict(list) 11 | 12 | def listen(self, event, callback): 13 | """ 14 | Assign a callback to an event. 15 | :param event: an event which triggers execution of the callback. 16 | Particular values are defined by derivative classes. 17 | :param callback: a callback to be fired when event occurs. 18 | """ 19 | self.listeners[event].append(callback) 20 | 21 | def unlisten(self, event, callback): 22 | """ 23 | Remove previously assigned callback. 24 | :return True in case the callback was successfully removed, False 25 | otherwise. 26 | """ 27 | try: 28 | self.listeners[event].remove(callback) 29 | except ValueError: 30 | return False 31 | else: 32 | return True 33 | 34 | @defer.inlineCallbacks 35 | def dispatchEvent(self, event, *args): 36 | """ 37 | Fire all callbacks assigned to a particular event. To be called by 38 | derivative classes. 39 | :param *args: Additional arguments to be passed to the callback 40 | function. 41 | """ 42 | for callback in self.listeners[event]: 43 | yield callback(event, self, *args) 44 | -------------------------------------------------------------------------------- /apns/notification.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import binascii 3 | import json 4 | import struct 5 | 6 | from apns.commands import NOTIFICATION 7 | from apns.utils import datetime_to_timestamp 8 | 9 | 10 | class NotificationError(Exception): 11 | """To be thrown upon failures on notification processing.""" 12 | pass 13 | 14 | 15 | class NotificationInvalidPriorityError(NotificationError): 16 | """ 17 | Thrown while packing a notification, if the notification priority field is 18 | invalid. 19 | """ 20 | pass 21 | 22 | 23 | class NotificationPayloadNotSerializableError(NotificationError): 24 | """ 25 | Thrown while packing a notification, if the notification payload field 26 | could not be serialized to JSON. 27 | """ 28 | pass 29 | 30 | 31 | class NotificationTokenUnhexlifyError(NotificationError): 32 | """ 33 | Thrown while packing a notification, if the notification token field could 34 | not be converted to binary from its hex representation. 35 | """ 36 | def __init__(self, msg): 37 | super(NotificationTokenUnhexlifyError, self).__init__(msg) 38 | 39 | 40 | class NotificationInvalidCommandError(NotificationError): 41 | """ 42 | Thrown while unpacking a notification, if the notification command field 43 | contains invalid value. 44 | """ 45 | pass 46 | 47 | 48 | class NotificationInvalidIdError(NotificationError): 49 | """ 50 | Thrown while unpacking a notification, if the notification structure is 51 | invalid. 52 | """ 53 | pass 54 | 55 | 56 | class Notification(object): 57 | """ 58 | A representation of the structure of a notification request, as defined in 59 | the iOS documentation. 60 | """ 61 | COMMAND = NOTIFICATION 62 | PRIORITY_NORMAL = 5 63 | PRIORITY_IMMEDIATELY = 10 64 | 65 | PRIORITIES = (PRIORITY_NORMAL, PRIORITY_IMMEDIATELY) 66 | 67 | PAYLOAD = 2 68 | TOKEN = 1 69 | PRIORITY = 5 70 | NOTIFICATION_ID = 3 71 | EXPIRE = 4 72 | 73 | EXPIRE_IMMEDIATELY = 0 74 | 75 | def __init__(self, payload=None, token=None, expire=None, 76 | priority=PRIORITY_NORMAL, iden=0): 77 | """ 78 | Init an instance of Notification. 79 | :param payload: object containing structure of payload to be sent to 80 | remote device. 81 | :param token: string containing target device token in hex 82 | :param expire: notification expire time as UNIX timestamp, 0 means that 83 | notification expires immediately. 84 | :param priority: notification priority, as described in iOS 85 | documentation 86 | :param iden: notification ID, as described in iOS documentation 87 | """ 88 | self.payload = payload 89 | self.token = token 90 | self.expire = expire 91 | self.priority = priority 92 | self.iden = iden 93 | 94 | def __str__(self): 95 | return '' % self.token 96 | 97 | def to_binary_string(self): 98 | """Pack the notification to binary form and return it as string.""" 99 | if self.priority not in self.PRIORITIES: 100 | raise NotificationInvalidPriorityError() 101 | 102 | try: 103 | token = binascii.unhexlify(self.token) 104 | except TypeError as error: 105 | raise NotificationTokenUnhexlifyError(error) 106 | 107 | try: 108 | payload = json.dumps(self.payload) 109 | except TypeError: 110 | raise NotificationPayloadNotSerializableError() 111 | 112 | fmt = ">BIBH{0}sBH{1}sBHIBHIBHB".format(len(token), len(payload)) 113 | 114 | expire = (0 if self.expire == self.EXPIRE_IMMEDIATELY else 115 | datetime_to_timestamp(self.expire)) 116 | 117 | # |COMMAND|FRAME-LEN|{token}|{payload}|{id:4}|{expire:4}|{priority:1} 118 | # 5 items, each 3 bytes prefix, then each item length 119 | length = 3*5 + len(token) + len(payload) + 4 + 4 + 1 120 | message = struct.pack(fmt, self.COMMAND, length, 121 | self.TOKEN, len(token), token, 122 | self.PAYLOAD, len(payload), payload, 123 | self.NOTIFICATION_ID, 4, self.iden, 124 | self.EXPIRE, 4, expire, 125 | self.PRIORITY, 1, self.priority) 126 | return message 127 | 128 | def from_binary_string(self, notification): 129 | """Unpack the notification from binary string.""" 130 | command = struct.unpack('>B', notification[0])[0] 131 | 132 | if command != self.COMMAND: 133 | raise NotificationInvalidCommandError() 134 | 135 | length = struct.unpack('>I', notification[1:5])[0] 136 | notification = notification[5:] 137 | offset = 0 138 | 139 | def next_item(offset): 140 | iden, length = struct.unpack('>BH', notification[offset:offset+3]) 141 | offset += 3 142 | payload = notification[offset:offset+length] 143 | offset += length 144 | 145 | if iden == self.PAYLOAD: 146 | payload = struct.unpack('>{0}s'.format(length), payload)[0] 147 | self.payload = json.loads(payload) 148 | elif iden == self.TOKEN: 149 | payload = struct.unpack('>{0}s'.format(length), payload)[0] 150 | self.token = binascii.hexlify(payload) 151 | elif iden == self.PRIORITY: 152 | self.priority = struct.unpack('>B', payload)[0] 153 | elif iden == self.NOTIFICATION_ID: 154 | self.iden = struct.unpack('>I', payload)[0] 155 | elif iden == self.EXPIRE: 156 | payload = struct.unpack('>I', payload)[0] 157 | self.expire = (self.EXPIRE_IMMEDIATELY if payload == 0 else 158 | datetime.fromtimestamp(payload)) 159 | else: 160 | raise NotificationInvalidIdError() 161 | 162 | return offset 163 | 164 | while offset < length: 165 | offset = next_item(offset) 166 | -------------------------------------------------------------------------------- /apns/tests/test_errorresponse.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | from twisted.trial.unittest import TestCase 3 | 4 | from apns.errorresponse import ( 5 | ErrorResponse, 6 | ErrorResponseInvalidCodeError, 7 | ErrorResponseInvalidCommandError 8 | ) 9 | 10 | 11 | MODULE = 'apns.errorresponse.' 12 | 13 | 14 | class ErrorResponseTestCase(TestCase): 15 | CLASS = MODULE + 'ErrorResponse.' 16 | 17 | def test_str(self): 18 | resp = ErrorResponse() 19 | resp.name = 'name' 20 | 21 | self.assertEqual(str(resp), '') 22 | 23 | @patch(CLASS + 'CODES', {0: 'invalid token'}) 24 | @patch(MODULE + 'struct.unpack') 25 | def test_properties_set(self, unpack_mock): 26 | unpack_mock.return_value = ErrorResponse.COMMAND, 0, 'identifier' 27 | resp = ErrorResponse() 28 | 29 | resp.from_binary_string('stream') 30 | 31 | self.assertEqual(resp.code, 0) 32 | self.assertEqual(resp.name, 'invalid token') 33 | self.assertEqual(resp.identifier, 'identifier') 34 | 35 | @patch(MODULE + 'struct.unpack') 36 | def test_from_binary_string_invalid_command(self, unpack_mock): 37 | unpack_mock.return_value = ErrorResponse.COMMAND + 1, None, None 38 | resp = ErrorResponse() 39 | 40 | with self.assertRaises(ErrorResponseInvalidCommandError): 41 | resp.from_binary_string('stream') 42 | 43 | @patch(CLASS + 'CODES', {0: 'invalid token'}) 44 | @patch(MODULE + 'struct.unpack') 45 | def test_from_binary_string_invalid_code(self, unpack_mock): 46 | unpack_mock.return_value = ErrorResponse.COMMAND, 1, None 47 | resp = ErrorResponse() 48 | 49 | with self.assertRaises(ErrorResponseInvalidCodeError): 50 | resp.from_binary_string('stream') 51 | 52 | @patch(CLASS + 'CODES', {0: 'invalid token'}) 53 | def test_from_binary_string_valid_input(self): 54 | resp = ErrorResponse() 55 | resp.from_binary_string(resp.to_binary_string(0, 123)) 56 | 57 | self.assertEqual(resp.code, 0) 58 | self.assertEqual(resp.name, 'invalid token') 59 | self.assertEqual(resp.identifier, 123) 60 | -------------------------------------------------------------------------------- /apns/tests/test_feedback.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from twisted.trial.unittest import TestCase 4 | 5 | from apns.feedback import Feedback 6 | 7 | 8 | class FeedbackTestCase(TestCase): 9 | 10 | def test_str(self): 11 | feedback = Feedback() 12 | feedback.when = 'when' 13 | feedback.token = 'token' 14 | 15 | self.assertEqual(str(feedback), '') 16 | 17 | def test_from_binary_string(self): 18 | t1 = datetime(2015, 1, 1, 12, 0, 0, 123456) 19 | stream = Feedback(t1, '00').to_binary_string() 20 | t2 = datetime(2015, 2, 1, 12, 0, 0, 123456) 21 | stream += Feedback(t2, '11').to_binary_string() 22 | 23 | feedbacks = Feedback.from_binary_string(stream) 24 | 25 | self.assertEqual(len(feedbacks), 2) 26 | self.assertEqual(feedbacks[0].when, t1.replace(microsecond=0)) 27 | self.assertEqual(feedbacks[0].token, '00') 28 | self.assertEqual(feedbacks[1].when, t2.replace(microsecond=0)) 29 | self.assertEqual(feedbacks[1].token, '11') 30 | -------------------------------------------------------------------------------- /apns/tests/test_feedbackclient.py: -------------------------------------------------------------------------------- 1 | from mock import Mock, patch 2 | 3 | from twisted.trial.unittest import TestCase 4 | 5 | from apns.feedbackclient import ( 6 | FeedbackClient, 7 | FeedbackClientFactory 8 | ) 9 | 10 | 11 | MODULE = 'apns.feedbackclient.' 12 | 13 | 14 | class FeedbackClientTestCase(TestCase): 15 | 16 | def test_connection_made(self): 17 | client = FeedbackClient() 18 | client.factory = Mock(hostname='opera.com', port=80) 19 | 20 | client.connectionMade() 21 | 22 | @patch(MODULE + 'Feedback.from_binary_string') 23 | def test_data_received(self, from_binary_string_mock): 24 | client = FeedbackClient() 25 | client.factory = Mock() 26 | data = Mock() 27 | 28 | client.dataReceived(data) 29 | 30 | from_binary_string_mock.assert_called_once_with(data) 31 | client.factory.feedbacksReceived(from_binary_string_mock.return_value) 32 | 33 | 34 | class FeedbackClientFactoryTestCase(TestCase): 35 | 36 | @patch(MODULE + 'ssl.PrivateCertificate.loadPEM', Mock()) 37 | @patch(MODULE + 'FeedbackClientFactory.ENDPOINTS', {'pub': ('foo', 'bar')}) 38 | def setUp(self): 39 | self.factory = FeedbackClientFactory('pub', __file__) 40 | 41 | @patch(MODULE + 'ReconnectingClientFactory.clientConnectionFailed') 42 | def test_client_connection_failed(self, client_connection_failed_mock): 43 | connector = Mock() 44 | reason = Mock() 45 | 46 | self.factory.clientConnectionFailed(connector, reason) 47 | 48 | client_connection_failed_mock.assert_called_once_with(self.factory, 49 | connector, 50 | reason) 51 | 52 | @patch(MODULE + 'ReconnectingClientFactory.clientConnectionLost') 53 | def test_client_connection_lost(self, client_connection_lost_mock): 54 | connector = Mock() 55 | reason = Mock() 56 | 57 | self.factory.clientConnectionLost(connector, reason) 58 | 59 | client_connection_lost_mock.assert_called_once_with(self.factory, 60 | connector, 61 | reason) 62 | 63 | def test_feedbacks_received(self): 64 | feedbacks = Mock() 65 | callback = Mock() 66 | event = self.factory.EVENT_FEEDBACKS_RECEIVED 67 | self.factory.listen(event, callback) 68 | 69 | self.factory.feedbacksReceived(feedbacks) 70 | 71 | callback.assert_called_once_with(event, self.factory, feedbacks) 72 | -------------------------------------------------------------------------------- /apns/tests/test_gatewayclient.py: -------------------------------------------------------------------------------- 1 | from mock import Mock, patch 2 | from twisted.internet import defer 3 | from twisted.trial.unittest import TestCase 4 | 5 | from apns.errorresponse import ErrorResponse 6 | from apns.gatewayclient import ( 7 | GatewayClient, 8 | GatewayClientFactory, 9 | GatewayClientNotSetError 10 | ) 11 | 12 | 13 | MODULE = 'apns.gatewayclient.' 14 | 15 | 16 | class GatewayClientTestCase(TestCase): 17 | 18 | def test_connection_made(self): 19 | client = GatewayClient() 20 | client.factory = Mock(hostname='opera.com', port=80) 21 | 22 | client.connectionMade() 23 | 24 | client.factory.connectionMade.assert_called_once_with(client) 25 | 26 | def test_send(self): 27 | client = GatewayClient() 28 | client.transport = Mock() 29 | notification = Mock() 30 | 31 | client.send(notification) 32 | 33 | client.transport.write.assert_called_once_with( 34 | notification.to_binary_string()) 35 | 36 | @patch(MODULE + 'ErrorResponse.from_binary_string') 37 | def test_data_received(self, from_binary_string_mock): 38 | client = GatewayClient() 39 | client.factory = Mock() 40 | data = Mock() 41 | 42 | client.dataReceived(data) 43 | 44 | from_binary_string_mock.assert_called_once_with(data) 45 | client.factory.errorReceived.assert_called_once() 46 | self.assertIsInstance(client.factory.errorReceived.call_args[0][0], 47 | ErrorResponse) 48 | 49 | 50 | class GatewayClientFactoryTestCase(TestCase): 51 | CLASS = MODULE + 'GatewayClientFactory.' 52 | 53 | @patch(MODULE + 'ssl.PrivateCertificate.loadPEM', Mock()) 54 | @patch(MODULE + 'GatewayClientFactory.ENDPOINTS', {'pub': ('foo', 'bar')}) 55 | def setUp(self): 56 | self.factory = GatewayClientFactory('pub', __file__) 57 | 58 | def test_connection_made(self): 59 | client = Mock() 60 | event = self.factory.EVENT_CONNECTION_MADE 61 | callback = Mock() 62 | self.factory.listen(event, callback) 63 | 64 | self.factory.connectionMade(client) 65 | 66 | self.assertEqual(self.factory.client, client) 67 | callback.assert_called_once_with(event, self.factory) 68 | 69 | @patch(CLASS + '_onConnectionLost') 70 | @patch(MODULE + 'ReconnectingClientFactory.clientConnectionFailed') 71 | def test_client_connection_failed(self, client_connection_failed_mock, 72 | on_connection_lost_mock): 73 | connector = Mock() 74 | reason = Mock() 75 | self.factory.clientConnectionFailed(connector, reason) 76 | 77 | on_connection_lost_mock.assert_called_once_with() 78 | client_connection_failed_mock.assert_called_once_with(self.factory, 79 | connector, 80 | reason) 81 | 82 | @patch(CLASS + '_onConnectionLost') 83 | @patch(MODULE + 'ReconnectingClientFactory.clientConnectionLost') 84 | def test_client_connection_lost(self, client_connection_lost_mock, 85 | on_connection_lost_mock): 86 | connector = Mock() 87 | reason = Mock() 88 | self.factory.clientConnectionLost(connector, reason) 89 | 90 | on_connection_lost_mock.assert_called_once_with() 91 | client_connection_lost_mock.assert_called_once_with(self.factory, 92 | connector, 93 | reason) 94 | 95 | def test_connected(self): 96 | self.assertFalse(self.factory.connected) 97 | self.factory.client = Mock() 98 | self.assertTrue(self.factory.connected) 99 | self.factory.client = None 100 | self.assertFalse(self.factory.connected) 101 | 102 | @defer.inlineCallbacks 103 | def test_send_client_not_set(self): 104 | with self.assertRaises(GatewayClientNotSetError): 105 | yield self.factory.send(Mock()) 106 | 107 | def test_send_client_set(self): 108 | notification = Mock() 109 | client = Mock() 110 | self.factory.client = client 111 | 112 | self.factory.send(notification) 113 | 114 | self.factory.client.send.assert_called_once_with(notification) 115 | 116 | def test_on_connection_lost(self): 117 | self.factory.client = Mock() 118 | event = self.factory.EVENT_CONNECTION_LOST 119 | callback = Mock() 120 | self.factory.listen(event, callback) 121 | 122 | self.factory._onConnectionLost() 123 | 124 | self.assertIsNone(self.factory.client) 125 | 126 | callback.assert_called_once_with(event, self.factory) 127 | 128 | def test_error_received(self): 129 | error = Mock() 130 | event = self.factory.EVENT_ERROR_RECEIVED 131 | callback = Mock() 132 | self.factory.listen(event, callback) 133 | 134 | self.factory.errorReceived(error) 135 | 136 | callback.assert_called_once_with(event, self.factory, error) 137 | -------------------------------------------------------------------------------- /apns/tests/test_listenable.py: -------------------------------------------------------------------------------- 1 | from mock import Mock 2 | from twisted.trial.unittest import TestCase 3 | 4 | from apns.listenable import Listenable 5 | 6 | 7 | class ListenableTestCase(TestCase): 8 | 9 | def setUp(self): 10 | self.listenable = Listenable() 11 | 12 | def test_listen(self): 13 | event = 'foo' 14 | callback = object() 15 | 16 | self.listenable.listen(event, callback) 17 | 18 | self.assertEqual(self.listenable.listeners[event], [callback]) 19 | 20 | def test_unlisten_unknown_event(self): 21 | self.assertFalse(self.listenable.unlisten('foo', object())) 22 | 23 | def test_unlisten_unknown_callback(self): 24 | event = 'foo' 25 | self.listenable.listen(event, object()) 26 | 27 | self.assertFalse(self.listenable.unlisten(event, object())) 28 | 29 | def test_unlisten(self): 30 | event = 'foo' 31 | callback = object() 32 | self.listenable.listen(event, callback) 33 | 34 | self.assertTrue(self.listenable.unlisten(event, callback)) 35 | 36 | def test_dispatch_event_no_callbacks(self): 37 | self.listenable.dispatchEvent('foo') 38 | 39 | def test_dispatch_event(self): 40 | event = 'foo' 41 | callback_1 = Mock() 42 | callback_2 = Mock() 43 | param_1 = Mock() 44 | param_2 = Mock() 45 | self.listenable.listen(event, callback_1) 46 | self.listenable.listen(event, callback_2) 47 | 48 | self.listenable.dispatchEvent(event, param_1, param_2) 49 | 50 | callback_1.assert_called_once_with(event, self.listenable, param_1, 51 | param_2) 52 | callback_2.assert_called_once_with(event, self.listenable, param_1, 53 | param_2) 54 | -------------------------------------------------------------------------------- /apns/tests/test_notification.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import struct 3 | 4 | from mock import patch 5 | from twisted.trial.unittest import TestCase 6 | 7 | from apns.notification import ( 8 | Notification, 9 | NotificationInvalidCommandError, 10 | NotificationInvalidIdError, 11 | NotificationInvalidPriorityError, 12 | NotificationPayloadNotSerializableError, 13 | NotificationTokenUnhexlifyError 14 | ) 15 | 16 | 17 | MODULE = 'apns.notification.' 18 | 19 | 20 | class NotificationTestCase(TestCase): 21 | CLASS = MODULE + 'Notification.' 22 | 23 | @patch(CLASS + 'PRIORITIES', [0]) 24 | def test_invalid_priority(self): 25 | notification = Notification() 26 | 27 | with self.assertRaises(NotificationInvalidPriorityError): 28 | notification.to_binary_string() 29 | 30 | @patch(CLASS + 'PRIORITIES', [0]) 31 | def test_invalid_str(self): 32 | notification = Notification(None, 'token', None, 0) 33 | self.assertEqual(str(notification), '') 34 | 35 | @patch(CLASS + 'PRIORITIES', [0]) 36 | def test_to_binary_string_payload_not_json_serializable(self): 37 | notification = Notification(set(), '0000', None, 0) 38 | 39 | with self.assertRaises(NotificationPayloadNotSerializableError): 40 | notification.to_binary_string() 41 | 42 | @patch(CLASS + 'PRIORITIES', [0]) 43 | def test_to_binary_string_token_unhexlify_error(self): 44 | notification = Notification('', '0', None, 0) 45 | 46 | with self.assertRaises(NotificationTokenUnhexlifyError) as ctx: 47 | notification.to_binary_string() 48 | 49 | self.assertEqual(str(ctx.exception), 'Odd-length string') 50 | 51 | @patch(CLASS + 'PRIORITIES', [0]) 52 | def test_to_binary_string(self): 53 | notification = Notification('', '00', datetime.now(), 0) 54 | 55 | stream = notification.to_binary_string() 56 | 57 | notification.from_binary_string(stream) 58 | 59 | @patch(CLASS + 'PRIORITIES', [0]) 60 | def test_from_binary_string_properties_set(self): 61 | now = datetime.now() 62 | stream = Notification('', '00', now, 0, 123).to_binary_string() 63 | notification = Notification() 64 | 65 | notification.from_binary_string(stream) 66 | 67 | self.assertEqual(notification.payload, '') 68 | self.assertEqual(notification.token, '00') 69 | self.assertEqual(notification.expire, now.replace(microsecond=0)) 70 | self.assertEqual(notification.priority, 0) 71 | self.assertEqual(notification.iden, 123) 72 | 73 | @patch(CLASS + 'PRIORITIES', [0]) 74 | def test_from_binary_string_invalid_command(self): 75 | notification = Notification('', '00', datetime.now(), 0) 76 | 77 | with self.assertRaises(NotificationInvalidCommandError): 78 | notification.from_binary_string( 79 | struct.pack('>B', notification.COMMAND + 1)) 80 | 81 | @patch(CLASS + 'PRIORITIES', [0]) 82 | def test_from_binary_string_invalid_id(self): 83 | now = datetime.now() 84 | stream = Notification('', '00', now, 0, 123).to_binary_string() 85 | notification = Notification() 86 | notification.EXPIRE = -1 87 | 88 | with self.assertRaises(NotificationInvalidIdError): 89 | notification.from_binary_string(stream) 90 | 91 | @patch(CLASS + 'EXPIRE_IMMEDIATELY', 123) 92 | @patch(CLASS + 'PRIORITIES', [0]) 93 | def test_expire_immediately(self): 94 | stream = Notification(payload='', 95 | token='00', 96 | expire=123, 97 | priority=0).to_binary_string() 98 | notification = Notification() 99 | notification.from_binary_string(stream) 100 | 101 | self.assertEqual(notification.expire, 123) 102 | -------------------------------------------------------------------------------- /apns/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from twisted.trial.unittest import TestCase 4 | 5 | from apns.utils import datetime_to_timestamp 6 | 7 | 8 | class DatetimeToTimestampTestCase(TestCase): 9 | 10 | def test_datetime(self): 11 | now = datetime.now() 12 | 13 | timestamp = datetime_to_timestamp(now) 14 | 15 | self.assertEqual(datetime.fromtimestamp(timestamp), 16 | now.replace(microsecond=0)) 17 | -------------------------------------------------------------------------------- /apns/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def datetime_to_timestamp(dt): 5 | """Produce UNIX timestamp from specified date.""" 6 | return int(time.mktime(dt.timetuple())) 7 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo -n "Version: " 4 | read version 5 | 6 | git tag $version 7 | git push --tags origin master 8 | python setup.py sdist upload -r pypi 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from distutils.core import setup 3 | 4 | 5 | VERSION = '0.13' 6 | URL = 'https://github.com/operasoftware/twisted-apns' 7 | DOWNLOAD_URL = URL + '/tarball/' + VERSION 8 | 9 | 10 | setup( 11 | name = 'twisted-apns', 12 | packages = ['apns'], 13 | version = VERSION, 14 | description = 'Twisted client for Apple Push Notification Service (APNs)', 15 | author = 'Michał Łowicki', 16 | author_email = 'mlowicki@opera.com', 17 | url = URL, 18 | download_url = DOWNLOAD_URL, 19 | keywords = ['twisted', 'apns'], 20 | classifiers = [], 21 | install_requires=['Twisted', 'pyOpenSSL'] 22 | ) 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27 3 | 4 | [testenv] 5 | deps = 6 | pytest==2.7.1 7 | mock==1.0.1 8 | commands = py.test 9 | --------------------------------------------------------------------------------