Verified Commit 67320932 authored by Richard Weinhold's avatar Richard Weinhold 🔨

rebuilds library for version 2.0

- introduces new multi handler pushing for a single message
- refactors APNS handler to use apples 'new' api.push.apple.com API
- improves error handling
parent 30eb4c8e
# PushNotification #
# PushNotification
... is a small php-library to wrap Apple, Google and Windows Push-Notifications into a simple syntax.
... is a small php-library to wrap Apple (APNS) and Google (FCM) Push-Notifications into a simple syntax.
Examples:
### Android: ###
### Android
```php
use ricwein\PushNotification\PushNotification;
use ricwein\PushNotification\Handler\GCMHandler;
use ricwein\PushNotification\{PushNotification, Message, Handler};
$push = new PushNotification(new GCMHandler());
$push->setServerToken('ExampleGooglePushToken12345678987654321');
$push->addDevice('device-token');
$push->send('message', 'title', ['payload' => 'data']);
$fcm = new Handler\FCM('ExampleGooglePushToken12345678987654321');
$message = new Message('message', 'title', ['payload' => 'data']);
$push = new PushNotification(['fcm' => $fcm]);
$push->send($message, ['<device-token>' => 'fcm']);
```
### iOS: ###
### iOS
```php
use ricwein\PushNotification\PushNotification;
use ricwein\PushNotification\Handler\APNSHandler;
use ricwein\PushNotification\{PushNotification, Message, Handler, Config};
$push = new PushNotification(new APNSHandler());
$push->setServer([
'token' => 'path/to/cert.pem',
'url' => 'ssl://gateway.sandbox.push.apple.com:2195',
]);
$push->addDevice('<device-token>');
$push->send('message', 'title', ['payload' => 'data']);
$apns = new Handler\APNS(Config::ENV_PRODUCTION, 'com.bundle.id', 'cert.pem');
$message = new Message('message', 'title', ['payload' => 'data']);
$push = new PushNotification(['apns' => $apns]);
$push->send($message, ['<device-token>' => 'apns']);
```
### mixed
### Windows: ###
Sending messages to multiple devices of difference operating systems is also simple:
```php
use ricwein\PushNotification\PushNotification;
use ricwein\PushNotification\Handler\WNSHandler;
$push = new PushNotification(new WNSHandler());
$push->setServer([
'token' => 'wns-push-token',
'url' => 'server.url',
]);
$push->addDevice([
'clientID' => 'clientSecret',
'OAuth2-Token',
use ricwein\PushNotification\{PushNotification, Message, Handler, Config};
$fcm = new Handler\FCM('ExampleGooglePushToken12345678987654321');
$apns = new Handler\APNS(Config::ENV_PRODUCTION, 'com.bundle.id', 'cert.pem');
$message = new Message('message', 'title');
$push = new PushNotification(['apns' => $apns, 'fcm' => $fcm]);
$push->send($message, [
'<ios-device-token1>' => 'apns',
'<ios-device-token2>' => 'apns',
'<android-device-token1>' => 'fcm',
'<android-device-token2>' => 'fcm',
]);
$push->send('message', 'title', ['payload' => 'data']);
```
## usage: ##
## usage
This class uses the root-namespace `ricwein\PushNotification`.
### init
### init ###
It's possible to init the PushNotification class with a specific push-handler:
```php
use ricwein\PushNotification\PushNotification;
use ricwein\PushNotification\Handler\GCMHandler;
$push = new PushNotification(new GCMHandler());
```
or without, and adding the push-handler later:
```php
use ricwein\PushNotification\PushNotification;
use ricwein\PushNotification\Handler\GCMHandler;
$push = new PushNotification();
$pushHandler = new GCMHandler();
$push->setHandler($pushHandler);
```
The libraries main class is called `PushNotification` and requires an array of available push-handlers for the constructor. It's possible to set an ID as the handlers array key, to allow assigning devices to the handler later on.
Available push-handler are:
- Apple: `PushNotification\Handler\APNSHandler`
- Google: `PushNotification\Handler\GCMHandler`
- Windows: `PushNotification\Handler\WNSHandler`
They're all extending `PushNotification\PushHandler`
- Apple: `PushNotification\Handler\APNS`
- Google: `PushNotification\Handler\FCM`
### configuration ###
They're all extending `PushNotification\Handler`
Since all push-settings are push-handler specific, the according handler has to been added to the PushNotification class before applying the configuration at the PushNotification.
### configuration
Settings as *server-token* and *server-url* can be set like:
Since all push-settings are push-handler specific, the settings are directly applied in the handler constructors.
- APNS:
```php
$push->setServerToken('server-token');
$push->setServerUrl('server-url');
```
or as an array:
APNS(
string $environment /* (Config::ENV_PRODUCTION / Config::ENV_DEVELOPMENT) */,
string $appBundleID,
string $certPath,
?string $certPassphrase = null,
?string $url = null,
int $timeout = 10
)
```
- FCM:
```php
$push->setServer([
'token' => 'server-token',
'url' => 'server-url',
]);
```
FCM(
string $token,
string $url = self::FCM_ENDPOINT,
int $timeout = 10
)
```
It's also possible to set the configuration directly at the push-handler:
It's also possible to have multiple push-handlers with different configurations like:
```php
$pushHandler->setServerToken('server-token');
$pushHandler->setServerUrl('server-url');
// or:
$pushHandler->setServer([
'token' => 'server-token',
'url' => 'server-url',
]);
// or even:
$pushHandler = new GCMHandler('server-token', 'server-url');
```
use ricwein\PushNotification\{PushNotification, Message, Handler, Config};
### client-devices ###
$apnsProd = new Handler\APNS(Config::ENV_PRODUCTION, 'com.bundle.id', 'cert.pem');
$apnsDev = new Handler\APNS(Config::ENV_DEVELOPMENT, 'com.bundle.id', 'cert-dev.pem');
$message = new Message('message', 'title');
$push = new PushNotification(['prod' => $apnsProd, 'dev' => $apnsDev]);
The class can send notifications to multiple devices at once. The device-push-tokens are escaped by default.
```php
$push->addDevice('push-token1');
$push->addDevice('push-token2');
// or:
$push->addDevice([
'push-token1',
'push-token2',
$push->send($message, [
'<ios-device-token1>' => 'prod',
'<ios-device-token2>' => 'dev',
]);
```
> Note: The WNSHandler allows adding either:
> - client-id -> client-secret pairs (as array key & value)
> - or adding directly OAuth2-tokens
> - and even mixed combinations of both
### sending
### sending ###
Sending is either available for a message object or a raw payload.
Sending a messages is as simple as that:
- A message object is translated into a native push-notification message with body and title for FCM or APNS before sending.
- A raw payload (array) is sent '*as it is*' which might **not** be a good idea, if you want to mix APNS and FCM in one request.
```php
$push->send('message');
$message = new Message('body', 'title');
$message->setSound('test.aiff')->setBadge(2)->setPriority(Config::PRIORITY_NORMAL);
$push->send($message, [...]);
```
Adding a title?
### error handling
```php
$push->send('message', 'title');
```
It's possible to add a payload as an array:
The `PushNotification::send()` method returns an `Result` object. This usually contains an array of per device errors. If everything succeeded, the entry is null. You can fetch failed device-messages with:
```php
$payload = ['data'];
$push->send('message', null, $payload);
$result = $push->send($message, [...]);
$errors = $result->getFailed();
```
Sometimes the given server or device-tokens are expired or sending simply failed. To check if sending was successfully you can use the return-value of send():
Error are saved as Exeptions, so it's possible to just throw them. To simply just throw the first error if one occurred, call:
```php
if (!$push->send('message')) {
throw new \Exception('oh-no: Sending PushNotifications failed.');
}
$push->send($message, [...])->throwOnFirstError();
```
### Exceptions ###
This class can throw some default \Exception or \UnexpectedValueException - mostly in case of incorrect configuration or unreachable servers.
{
"name": "ricwein/push-notifications",
"description": "Push Notifications for iOS (APNS), Android (GCM/FCM) and Windows (WNS)",
"description": "Push Notifications for iOS (APNS), Android (FCM)",
"type": "library",
"license": "MIT",
"authors": [
......
<?php
namespace ricwein\PushNotification;
abstract class Config
{
public const PRIORITY_NORMAL = 0b01;
public const PRIORITY_HIGH = 0b10;
public const ENV_PRODUCTION = 'prod';
public const ENV_DEVELOPMENT = 'dev';
public const ENV_CUSTOM = 'custom';
public const SOUND_DEFAULT = 'default';
}
<?php
/**
* @author Richard Weinhold
*/
namespace ricwein\PushNotification;
use Exception;
use RuntimeException;
/**
* PushHandler, providing base push operations
*/
abstract class Handler
{
/**
* @var string[]
*/
protected $devices = [];
public function addDevice(string $token): void
{
$this->devices[] = $token;
}
/**
* @param Message $message
* @return array<string, Exception|null>
*/
abstract public function send(Message $message): array;
/**
* @param array $payload
* @param int $priority
* @return array<string, Exception|null>
*/
abstract public function sendRaw(array $payload, int $priority = Config::PRIORITY_HIGH): array;
}
<?php
namespace ricwein\PushNotification\Handler;
use ricwein\PushNotification\Config;
use ricwein\PushNotification\Handler;
use ricwein\PushNotification\Message;
use RuntimeException;
class APNS extends Handler
{
private const URLS = [
Config::ENV_DEVELOPMENT => 'https://api.sandbox.push.apple.com:443/3/device',
Config::ENV_PRODUCTION => 'https://api.push.apple.com:443/3/device',
];
/**
* @var string
*/
private $endpoint;
/**
* @var string
*/
private $appBundleID;
/**
* @var int
*/
private $port;
/**
* @var string
*/
private $certPath;
/**
* @var string|null
*/
private $certPassphrase;
/**
* @var int
*/
private $timeout;
public function __construct(string $environment, string $appBundleID, string $certPath, ?string $certPassphrase = null, ?string $url = null, int $timeout = 10)
{
if ($url === null && isset(static::URLS[$environment])) {
$url = static::URLS[$environment];
} elseif ($url === null) {
throw new RuntimeException("Unknown or unsupported environment {$environment}", 500);
}
if (false === $urlComponents = parse_url($url)) {
throw new RuntimeException("Invalid endpoint-url given for APNS, failed to parse: {$url}", 500);
}
if (!isset($urlComponents['host'])) {
throw new RuntimeException("Invalid endpoint-url given for APNS, missing host in: {$url}", 500);
}
$this->endpoint = sprintf("%s://%s%s", $urlComponents['scheme'] ?? 'https', $urlComponents['host'], $urlComponents['path'] ?? '/3/device');
$this->port = !empty($urlComponents['port']) ? (int)$urlComponents['port'] : 443;
if (!file_exists($certPath) || !is_readable($certPath)) {
throw new RuntimeException("Certificate not found or not readable for path: {$certPath}", 404);
}
$this->appBundleID = $appBundleID;
$this->certPath = $certPath;
$this->certPassphrase = $certPassphrase;
$this->timeout = $timeout;
}
public function send(Message $message): array
{
if (count($this->devices) < 1) {
return [];
}
$payload = array_merge([
'aps' => [
'alert' => $message->getTitle() !== null ? ['title' => $message->getTitle(), 'body' => $message->getBody()] : $message->getBody(),
'badge' => $message->getBadge(),
'sound' => $message->getSound(),
]
], $message->getPayload());
return $this->sendRaw($payload, $message->getPriority());
}
public function sendRaw(array $payload, int $priority = Config::PRIORITY_HIGH): array
{
if (count($this->devices) < 1) {
return [];
}
$content = json_encode($payload, JSON_UNESCAPED_UNICODE);
$headers = [
sprintf('apns-priority: %d', $priority === Config::PRIORITY_HIGH ? 10 : 5),
"apns-topic: {$this->appBundleID}",
"Content-Type: application/json",
];
$options = [
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0,
CURLOPT_PORT => $this->port,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $content,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_SSLCERT => $this->certPath,
];
if ($this->certPassphrase !== null) {
$options[CURLOPT_KEYPASSWD] = $this->certPassphrase;
}
$feedback = [];
$curl = curl_init();
try {
foreach ($this->devices as $deviceToken) {
// cleanup device tokens
$deviceToken = str_replace(' ', '', trim($deviceToken, '<> '));
$options[CURLOPT_URL] = "{$this->endpoint}/{$deviceToken}";
// setup curl for device push notification
curl_setopt_array($curl, $options);
// execute request
$result = curl_exec($curl);
$httpStatusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
if ($result === false) {
$feedback[$deviceToken] = new RuntimeException("Request failed with.", 500);
continue;
}
if (200 !== $httpStatusCode) {
$errorCode = curl_errno($curl);
$error = curl_error($curl);
$result = @json_decode($result, true);
if (isset($result['reason'])) {
$feedback[$deviceToken] = new RuntimeException("Request failed with: [{$errorCode}]: {$error} - Reason: {$result['reason']}", $httpStatusCode);
} else {
$feedback[$deviceToken] = new RuntimeException("Request failed with: [{$errorCode}]: {$error}", $httpStatusCode);
}
continue;
}
$feedback[$deviceToken] = null;
}
return $feedback;
} finally {
curl_close($curl);
}
}
}
<?php
/**
* @author Richard Weinhold
*/
namespace ricwein\PushNotification\Handler;
use ricwein\PushNotification\PushHandler;
use RuntimeException;
use UnexpectedValueException;
/**
* PushHandler for Apple Push Notification Service
*/
class APNSHandler extends PushHandler
{
/**
* @var array
*/
protected $_server = [
'token' => '',
'url' => 'ssl://gateway.push.apple.com:2195',
'passphrase' => null,
];
/**
* build binary notification-package
* @param string $deviceToken
* @param string $payload json
* @param array $arbitrary additional settings
* @param int $version push-version (1/2)
* @return string
* @throws UnexpectedValueException
*/
protected function _buildNotification(string $deviceToken, string $payload, array $arbitrary = [], int $version = 1): string
{
// set default arbitrary settings
$arbitrary = array_merge([
'expire' => 0,
'messageID' => 0,
'priority' => 10,
], $arbitrary);
// cleanup device tokens
$deviceToken = str_replace(' ', '', trim($deviceToken, '<> '));
// build notification
if ((int)$version === 1) {
$notification = pack('C', 1); // Command 1
$notification .= pack('N', (int)$arbitrary['messageID']); // notification id
$notification .= pack('N', ($arbitrary['expire'] > 0 ? time() + $arbitrary['expire'] : 0)); // expiration timestamps
$notification .= pack('nH*', 32, $deviceToken); // device-token
$notification .= pack('n', strlen($payload)) . $payload; // payload
return $notification;
}
if ((int)$version === 2) {
// build notification
$notification = pack('CnH*', 1, 32, $deviceToken); // device-token
$notification .= pack('CnA*', 2, strlen($payload), $payload); // payload
$notification .= pack('CnN', 3, 4, (int)$arbitrary['messageID']); // notification id
$notification .= pack('CnN', 4, 4, ($arbitrary['expire'] > 0 ? time() + $arbitrary['expire'] : 0)); // expiration timestamps
$notification .= pack('CnC', 5, 1, (int)$arbitrary['priority']); // notification priority
// pack notification into frame
$frame = pack('C', 2); // Command 2
$frame .= pack('N', strlen($notification)) . $notification; // notification
return $frame;
}
throw new UnexpectedValueException('Unknown Command Version', 500);
}
/**
* send notification to Apples APNS servers
* @param string $message
* @param string|null $title
* @param array $payload
* @param array $devices
* @return bool
* @throws UnexpectedValueException
* @throws RuntimeException
*
*/
public function send(string $message, ?string $title, array $payload, array $devices): bool
{
$message = trim(stripslashes($message));
// build payload
$payload = array_replace_recursive(['aps' => [
'alert' => $title !== null ? ['title' => $title, 'body' => $message] : $message,
'badge' => 1,
'sound' => 'default',
]], $payload);
return $this->sendRaw($payload, $devices);
}
/**
* build and send Notification from raw payload
* @param array $payload
* @param array $devices
* @return bool
* @throws UnexpectedValueException
* @throws RuntimeException
*/
public function sendRaw(array $payload, array $devices): bool
{
// set default values
$result = true;
$arbitrary = ['command' => 1];
// extract arbitrary settings
foreach (['expire', 'messageID', 'priority', 'command'] as $key) {
if (isset($payload[$key])) {
$arbitrary[$key] = (int)abs($payload[$key]);
unset($payload[$key]);
} elseif (isset($this->_server[$key])) {
$arbitrary[$key] = (int)abs($this->_server[$key]);
}
}
// open context
$ctx = stream_context_create();
// check and set cert-path
$certpath = realpath($this->_server['token']);
if (empty($certpath) || $certpath === DIRECTORY_SEPARATOR || !is_file($certpath)) {
throw new UnexpectedValueException("Invalid cert-file: {$certpath}", 500);
}
if (!stream_context_set_option($ctx, 'ssl', 'local_cert', $certpath)) {