From 2f5cfc02d528f647c211c78debbf4560fe0364d2 Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 7 Sep 2021 14:32:58 +0300 Subject: [PATCH 1/5] #BE24: fixed APN notification library --- src/Apn.php | 505 +++++++++++++++++----------------------------------- src/Gcm.php | 2 +- 2 files changed, 168 insertions(+), 339 deletions(-) diff --git a/src/Apn.php b/src/Apn.php index ae6a3c1..db1662e 100644 --- a/src/Apn.php +++ b/src/Apn.php @@ -6,147 +6,51 @@ class Apn extends PushService implements PushServiceInterface { - - const MAX_ATTEMPTS = 3; + const APNS_DEVELOPMENT_SERVER = 'https://api.development.push.apple.com'; + const APNS_PRODUCTION_SERVER = 'https://api.push.apple.com'; + const APNS_PORT = 443; + const APNS_PATH_SCHEMA = '/3/device/{token}'; /** - * Url for development purposes + * Number of concurrent requests to multiplex in the same connection. * - * @var string + * @var int */ - private $sandboxUrl = 'ssl://gateway.sandbox.push.apple.com:2195'; + private $nbConcurrentRequests = 20; /** - * Url for production + * Number of maximum concurrent connections established to the APNS servers. * - * @var string - */ - private $productionUrl = 'ssl://gateway.push.apple.com:2195'; - - /** - * Feedback SandBox url - * @var string - */ - private $feedbackSandboxUrl = 'ssl://feedback.sandbox.push.apple.com:2196'; - - /** - * Feedback Production url - * @var string + * @var int */ - private $feedbackProductionUrl = 'ssl://feedback.push.apple.com:2196'; + private $maxConcurrentConnections = 1; /** - * It's dynamically filled based on the dry_run parameter. + * Flag to know if we should automatically close connections to the APNS servers or keep them alive. * - * @var string + * @var bool */ - private $feedbackUrl; + private $autoCloseConnections = true; /** - * The number of attempts to re-try before failing. - * Set to zero for unlimited attempts. + * Current curl_multi handle instance. * - * @var int + * @var resource */ - private $maxAttempts = self::MAX_ATTEMPTS; - - private $attempts = 0; + private $curlMultiHandle; /** * Apn constructor. */ public function __construct() { - $this->url = $this->productionUrl; - - $this->config = $this->initializeConfig('apn'); - - $this->setProperGateway(); - } - - /** - * Call parent method. - * Check if there is dry_run parameter in config data. Set the service url according to the dry_run value. - * - * @param array $config - * @return void - */ - public function setConfig(array $config) - { - parent::setConfig($config); - - $this->setProperGateway(); - $this->setRetryAttemptsIfConfigured(); - } - - /** - *Set the correct Gateway url and the Feedback url based on dry_run param. - * - * @return void - */ - private function setProperGateway() - { - if (isset($this->config['dry_run'])) { - if ($this->config['dry_run']) { - $this->setUrl($this->sandboxUrl); - $this->feedbackUrl = $this->feedbackSandboxUrl; - - } else { - $this->setUrl($this->productionUrl); - $this->feedbackUrl = $this->feedbackProductionUrl; - } - } - } - - /** - * Configure re-try attempts. - * - * @return void - */ - private function setRetryAttemptsIfConfigured() - { - if (isset($this->config['connection_attempts']) && - is_numeric($this->config['connection_attempts'])) { - $this->maxAttempts = $this->config['connection_attempts']; - } - } - - /** - * Determines whether the connection attempts should be unlimited. - * - * @return bool - */ - private function isUnlimitedAttempts() - { - return $this->maxAttempts == 0; - } - - /** - * Check if can retry a connection - * - * @return bool - */ - private function canRetry() - { - if ($this->isUnlimitedAttempts()) { - return true; + if (!defined('CURL_HTTP_VERSION_2')) { + define('CURL_HTTP_VERSION_2', 3); } - $this->attempts++; + $this->url = self::APNS_PRODUCTION_SERVER; - return $this->attempts < $this->maxAttempts; - } - - /** - * Reset connection attempts - * - * @return $this - */ - private function resetAttempts() - { - $this->attempts = 0; - - return $this; + $this->config = $this->initializeConfig('apn'); } /** @@ -170,274 +74,199 @@ public function getUnregisteredDeviceTokens(array $devices_token) } /** - * Set the feedback with no exist any certificate. - * - * @return mixed|void + * Send Push Notification + * @param array $deviceTokens + * @param array $message + * @return \stdClass APN Response */ - private function messageNoExistCertificate() + public function send(array $deviceTokens, array $message) { - $response = [ - 'success' => false, - 'error' => "Please, add your APN certificate to the iosCertificates folder." . PHP_EOL - ]; + $responseCollection = []; - $this->setFeedback(json_decode(json_encode($response))); - } + if (!$this->curlMultiHandle) { + $this->curlMultiHandle = curl_multi_init(); - /** - * Check if the certificate file exist. - * @return bool - */ - private function existCertificate() - { - if (isset($this->config['certificate'])) { - $certificate = $this->config['certificate']; - if (!file_exists($certificate)) { - $this->messageNoExistCertificate(); - return false; + if (!defined('CURLPIPE_MULTIPLEX')) { + define('CURLPIPE_MULTIPLEX', 2); } - return true; + curl_multi_setopt($this->curlMultiHandle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); + if (defined('CURLMOPT_MAX_HOST_CONNECTIONS')) { + curl_multi_setopt($this->curlMultiHandle, CURLMOPT_MAX_HOST_CONNECTIONS, $this->maxConcurrentConnections); + } } - $this->messageNoExistCertificate(); - return false; - } + $mh = $this->curlMultiHandle; + $errors = []; - /** - * Compose the stream socket - * - * @return resource - */ - private function composeStreamSocket() - { - $ctx = stream_context_create(); - - //Already checked if certificate exists. - $certificate = $this->config['certificate']; - stream_context_set_option($ctx, 'ssl', 'local_cert', $certificate); - - if (isset($this->config['passPhrase'])) { - $passPhrase = $this->config['passPhrase']; - if (!empty($passPhrase)) { - stream_context_set_option($ctx, 'ssl', 'passphrase', $passPhrase); - } + $i = 0; + while (!empty($deviceTokens) && $i++ < $this->nbConcurrentRequests) { + $deviceToken = array_pop($deviceTokens); + curl_multi_add_handle($mh, $this->prepareHandle($deviceToken, $message)); } - if (isset($this->config['passFile'])) { - $passFile = $this->config['passFile']; - if (file_exists($passFile)) { - stream_context_set_option($ctx, 'ssl', 'local_pk', $passFile); + // Clear out curl handle buffer + do { + $execrun = curl_multi_exec($mh, $running); + } while ($execrun === CURLM_CALL_MULTI_PERFORM); + + // Continue processing while we have active curl handles + while ($running > 0 && $execrun === CURLM_OK) { + // Block until data is available + $select_fd = curl_multi_select($mh); + // If select returns -1 while running, wait 250 microseconds before continuing + // Using curl_multi_timeout would be better but it isn't available in PHP yet + // https://php.net/manual/en/function.curl-multi-select.php#115381 + if ($running && $select_fd === -1) { + usleep(250); } - } - return $ctx; - } + // Continue to wait for more data if needed + do { + $execrun = curl_multi_exec($mh, $running); + } while ($execrun === CURLM_CALL_MULTI_PERFORM); - /** - * Create the connection to APNS server - * If some error, the error is stored in class feedback property. - * IF OKAY, return connection - * - * @return bool|resource - */ - private function openConnectionAPNS($ctx) - { - $fp = false; + // Start reading results + while ($done = curl_multi_info_read($mh)) { + $handle = $done['handle']; - // Open a connection to the APNS server - try { - $fp = stream_socket_client( - $this->url, - $err, - $errstr, - 60, - STREAM_CLIENT_CONNECT|STREAM_CLIENT_PERSISTENT, - $ctx - ); + $result = curl_multi_getcontent($handle); - stream_set_blocking($fp, 0); + // find out which token the response is about + $token = curl_getinfo($handle, CURLINFO_PRIVATE); - if (!$fp) { - $response = ['success' => false, 'error' => "Failed to connect: $err $errstr" . PHP_EOL]; + $responseParts = explode("\r\n\r\n", $result, 2); + $headers = ''; + $body = ''; + if (isset($responseParts[0])) { + $headers = $responseParts[0]; + } + if (isset($responseParts[1])) { + $body = $responseParts[1]; + } - $this->setFeedback(json_decode(json_encode($response))); + $statusCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); + if ($statusCode === 0) { + $errors[] = curl_error($handle); + continue; + } + $responseCollection[] = [ + 'status' => $statusCode, + 'headers' => $headers, + 'body' => (string)$body, + 'token' => $token + ]; + curl_multi_remove_handle($mh, $handle); + curl_close($handle); + + if (!empty($deviceTokens)) { + $deviceToken = array_pop($deviceTokens); + curl_multi_add_handle($mh, $this->prepareHandle($deviceToken, $message)); + $running++; + } } + } - } catch (\Exception $e) { - //if stream socket can't be established, try again - if ($this->canRetry()) { - return $this->openConnectionAPNS($ctx); - } + if ($this->autoCloseConnections) { + curl_multi_close($mh); + $this->curlMultiHandle = null; + } - $response = ['success' => false, 'error' => 'Connection problem: ' . $e->getMessage() . PHP_EOL]; - $this->setFeedback(json_decode(json_encode($response))); + //Set the global feedback + $this->setFeedback(json_decode(json_encode($responseCollection))); - } finally { - $this->resetAttempts(); - return $fp; - } + return $responseCollection; } /** - * Send Push Notification - * @param array $deviceTokens - * @param array $message - * @return \stdClass APN Response + * Get Url for APNs production server. + * + * @param Notification $notification + * @return string */ - public function send(array $deviceTokens, array $message) + private function getProductionUrl(string $deviceToken) { + return self::APNS_PRODUCTION_SERVER . $this->getUrlPath($deviceToken); + } - /** - * If there isn't certificate returns the feedback. - * Feedback has been loaded in existCertificate method if no certificate found - */ - if (!$this->existCertificate()) { - return $this->feedback; - } - - // Encode the payload as JSON - $payload = json_encode($message); - - //When sending a notification we prepare a clean feedback - $feedback = $this->initializeFeedback(); - - foreach ($deviceTokens as $token) { - /** - * Open APN connection - */ - $ctx = $this->composeStreamSocket(); - - $fp = $this->openConnectionAPNS($ctx); - if (!$fp) { - return $this->feedback; - } - - - // Build the binary notification - //Check if the token is numeric not to get PHP Warnings with pack function. - if (ctype_xdigit($token)) { - $msg = chr(0) . pack('n', 32) . pack('H*', $token) . pack('n', strlen($payload)) . $payload; - } else { - $feedback['tokenFailList'][] = $token; - $feedback['failure'] += 1; - continue; - } - - $result = fwrite($fp, $msg, strlen($msg)); - - if (!$result) { - $feedback['tokenFailList'][] = $token; - $feedback['failure'] += 1; - - } else { - $feedback['success'] += 1; - } - - // Close the connection to the server - if ($fp) { - fclose($fp); - } - - } - - /** - * Retrieving the apn feedback - */ - $apnsFeedback = $this->apnsFeedback(); - - /** - * Merge the apn feedback to our custom feedback if there is any. - */ - if (!empty($apnsFeedback)) { - $feedback = array_merge($feedback, $apnsFeedback); - - $feedback = $this->updateCustomFeedbackValues($apnsFeedback, $feedback, $deviceTokens); - } - - //Set the global feedback - $this->setFeedback(json_decode(json_encode($feedback))); + /** + * Get Url for APNs sandbox server. + * + * @param Notification $notification + * @return string + */ + private function getSandboxUrl(string $deviceToken) + { + return self::APNS_DEVELOPMENT_SERVER . $this->getUrlPath($deviceToken); + } - return $this->feedback; + /** + * Get Url path. + * + * @param Notification $notification + * @return mixed + */ + private function getUrlPath(string $deviceToken) + { + return str_replace("{token}", $deviceToken, self::APNS_PATH_SCHEMA); } /** - * Get the unregistered device tokens from the apns feedback. - * Connect to apn server in order to collect the tokens of the apps which were removed from the device. + * Decorate headers * * @return array */ - public function apnsFeedback() { - - $feedback_tokens = array(); - - if (!$this->existCertificate()) { - return $feedback_tokens; - } - - //connect to the APNS feedback servers - $ctx = $this->composeStreamSocket(); - - // Open a connection to the APNS server - try { - $apns = stream_socket_client($this->feedbackUrl, $errcode, $errstr, 60, STREAM_CLIENT_CONNECT, $ctx); - - //Read the data on the connection: - while (!feof($apns)) { - $data = fread($apns, 38); - if (strlen($data)) { - $feedback_tokens['apnsFeedback'][] = unpack("N1timestamp/n1length/H*devtoken", $data); - } - } - fclose($apns); - - } catch (\Exception $e) { - //if stream socket can't be established, try again - if ($this->canRetry()) { - return $this->apnsFeedback(); - } - - $response = [ - 'success' => false, - 'error' => 'APNS feedback connection problem: ' . $e->getMessage() . PHP_EOL - ]; - - $this->setFeedback(json_decode(json_encode($response))); - - } finally { - $this->resetAttempts(); - return $feedback_tokens; + public function decorateHeaders(array $headers): array + { + $decoratedHeaders = []; + foreach ($headers as $name => $value) { + $decoratedHeaders[] = $name . ': ' . $value; } + return $decoratedHeaders; } /** - * Update the success and failure values based on apple feedback - * - * @param $apnsFeedback - * @param $feedback - * @param $deviceTokens - * - * @return array $feedback + * @param $token + * @param array $message + * @param $request + * @param array $deviceTokens */ - private function updateCustomFeedbackValues($apnsFeedback, $feedback, $deviceTokens) + public function prepareHandle($deviceToken, array $message) { + $uri = false === $this->config['dry_run'] ? $this->getProductionUrl($deviceToken) : $this->getSandboxUrl($deviceToken); + $headers = $message['headers']; + unset($message['headers']); + $body = json_encode($message); + + $config = $this->config; + + $options = [ + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2, + CURLOPT_URL => $uri, + CURLOPT_PORT => self::APNS_PORT, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_HEADER => true, + + CURLOPT_SSLCERT => $config['certificate'], + CURLOPT_SSLCERTPASSWD => $config['passPhrase'], + CURLOPT_SSL_VERIFYPEER => true + ]; - //Add failures amount based on apple feedback to our custom feedback - $feedback['failure'] += count($apnsFeedback['apnsFeedback']); - - //apns tokens - $apnsTokens = Arr::pluck($apnsFeedback['apnsFeedback'], 'devtoken'); - - foreach ($deviceTokens as $token) { - if (in_array($token, $apnsTokens)) { - $feedback['success'] -= 1; - $feedback['tokenFailList'][] = $token; - } + $ch = curl_init(); + curl_setopt_array($ch, $options); + if (!empty($headers)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $this->decorateHeaders($headers)); } - return $feedback; + // store device token to identify response + curl_setopt($ch, CURLOPT_PRIVATE, $deviceToken); + + return $ch; } } \ No newline at end of file diff --git a/src/Gcm.php b/src/Gcm.php index 46cb3a4..56b1664 100644 --- a/src/Gcm.php +++ b/src/Gcm.php @@ -121,7 +121,7 @@ protected function addRequestHeaders() { return [ 'Authorization' => 'key=' . $this->config['apiKey'], - 'Content-Type:' =>'application/json' + 'Content-Type' =>'application/json' ]; } From 8636d377020f58d4f1952d946dfe42642e788c3d Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 7 Sep 2021 14:40:41 +0300 Subject: [PATCH 2/5] #BE24: fixed APN notification library --- src/Apn.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Apn.php b/src/Apn.php index db1662e..2a8a46e 100644 --- a/src/Apn.php +++ b/src/Apn.php @@ -151,7 +151,8 @@ public function send(array $deviceTokens, array $message) continue; } - $responseCollection[] = [ + $responseCollection = [ + 'success' => $statusCode == 200, 'status' => $statusCode, 'headers' => $headers, 'body' => (string)$body, From 67ffc99472b522904eacdfd1c80de0dcde3d14e8 Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 7 Sep 2021 15:30:07 +0300 Subject: [PATCH 3/5] #BE24: fixed APN notification library --- src/Apn.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Apn.php b/src/Apn.php index 2a8a46e..6a49601 100644 --- a/src/Apn.php +++ b/src/Apn.php @@ -81,7 +81,11 @@ public function getUnregisteredDeviceTokens(array $devices_token) */ public function send(array $deviceTokens, array $message) { - $responseCollection = []; + $responseCollection = [ + 'success' => true, + 'errors' => [], + 'responses' => [], + ]; if (!$this->curlMultiHandle) { $this->curlMultiHandle = curl_multi_init(); @@ -147,12 +151,18 @@ public function send(array $deviceTokens, array $message) $statusCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); if ($statusCode === 0) { - $errors[] = curl_error($handle); + $responseCollection['errors'][] = [ + 'status' => $statusCode, + 'headers' => $headers, + 'body' => curl_error($handle), + 'token' => $token + ]; continue; } - $responseCollection = [ - 'success' => $statusCode == 200, + $responseCollection['success'] = $responseCollection['success'] & $statusCode == 200; + + $responseCollection['responses'][] = [ 'status' => $statusCode, 'headers' => $headers, 'body' => (string)$body, From ca8f2c1e2e66f70792f614ea9aae8e7b2a26f284 Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 7 Sep 2021 16:28:45 +0300 Subject: [PATCH 4/5] #BE24: fixed APN notification library --- src/Apn.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apn.php b/src/Apn.php index 6a49601..3bcee6f 100644 --- a/src/Apn.php +++ b/src/Apn.php @@ -160,7 +160,7 @@ public function send(array $deviceTokens, array $message) continue; } - $responseCollection['success'] = $responseCollection['success'] & $statusCode == 200; + $responseCollection['success'] = $responseCollection['success'] && $statusCode == 200; $responseCollection['responses'][] = [ 'status' => $statusCode, From f0ad0613bcb7657b008a885c8e2e6c4b6d997609 Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 8 Sep 2021 15:15:20 +0300 Subject: [PATCH 5/5] Fixed tests and Apn response message format --- src/Apn.php | 49 +++++++++++++++++++++++++++++++--- src/Gcm.php | 6 ++--- tests/PushNotificationTest.php | 15 ----------- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/Apn.php b/src/Apn.php index 3bcee6f..a58df87 100644 --- a/src/Apn.php +++ b/src/Apn.php @@ -81,10 +81,14 @@ public function getUnregisteredDeviceTokens(array $devices_token) */ public function send(array $deviceTokens, array $message) { + if (false == $this->existCertificate()) { + return $this->feedback; + } + $responseCollection = [ 'success' => true, - 'errors' => [], - 'responses' => [], + 'error' => '', + 'results' => [], ]; if (!$this->curlMultiHandle) { @@ -151,7 +155,9 @@ public function send(array $deviceTokens, array $message) $statusCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); if ($statusCode === 0) { - $responseCollection['errors'][] = [ + $responseCollection['success'] = false; + + $responseCollection['error'] = [ 'status' => $statusCode, 'headers' => $headers, 'body' => curl_error($handle), @@ -162,7 +168,7 @@ public function send(array $deviceTokens, array $message) $responseCollection['success'] = $responseCollection['success'] && $statusCode == 200; - $responseCollection['responses'][] = [ + $responseCollection['results'][] = [ 'status' => $statusCode, 'headers' => $headers, 'body' => (string)$body, @@ -280,4 +286,39 @@ public function prepareHandle($deviceToken, array $message) return $ch; } + /** + * Set the feedback with no exist any certificate. + * + * @return mixed|void + */ + private function messageNoExistCertificate() + { + $response = [ + 'success' => false, + 'error' => "Please, add your APN certificate to the iosCertificates folder." . PHP_EOL + ]; + + $this->setFeedback(json_decode(json_encode($response))); + } + + /** + * Check if the certificate file exist. + * @return bool + */ + private function existCertificate() + { + if (isset($this->config['certificate'])) { + $certificate = $this->config['certificate']; + if (!file_exists($certificate)) { + $this->messageNoExistCertificate(); + return false; + } + + return true; + } + + $this->messageNoExistCertificate(); + return false; + } + } \ No newline at end of file diff --git a/src/Gcm.php b/src/Gcm.php index 56b1664..fe29002 100644 --- a/src/Gcm.php +++ b/src/Gcm.php @@ -28,9 +28,9 @@ public function setApiKey($apiKey) */ public function __construct() { - $this->url = 'https://android.googleapis.com/gcm/send'; - - $this->config = $this->initializeConfig('gcm'); + $this->url = 'https://fcm.googleapis.com/fcm/send'; + + $this->config = $this->initializeConfig('fcm'); $this->client = new Client; } diff --git a/tests/PushNotificationTest.php b/tests/PushNotificationTest.php index 4ceb626..b8344e3 100644 --- a/tests/PushNotificationTest.php +++ b/tests/PushNotificationTest.php @@ -136,21 +136,6 @@ public function apn_without_certificate() $this->assertFalse($push->feedback->success); } - /** @test */ - public function apn_dry_run_option_update_the_apn_url() - { - $push = new PushNotification('apn'); - - $push->setConfig(['dry_run'=>false]); - - $this->assertEquals('ssl://gateway.push.apple.com:2195', $push->url); - - $push->setConfig(['dry_run'=>true]); - - $this->assertEquals('ssl://gateway.sandbox.push.apple.com:2195', $push->url); - } - - /** @test */ public function fcm_assert_send_method_returns_an_stdClass_instance() {