From d706a07ea60818963f492d1be2a74cbe406d8930 Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sat, 12 Apr 2025 11:44:45 +0200 Subject: [PATCH 1/8] [Notifier][Firebase] Require ext-openssl New Firebase API uses JWT authorization and OpenSSL is needed for signing it. --- src/Symfony/Component/Notifier/Bridge/Firebase/composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json index fa18127a3f874..bda6193ce5dc8 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json @@ -17,6 +17,7 @@ ], "require": { "php": ">=8.2", + "ext-openssl": "*", "symfony/http-client": "^6.4|^7.0", "symfony/notifier": "^7.2" }, From d5bd6b6b33cba2f66a653ecb3077c3b656e1e324 Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sat, 12 Apr 2025 11:56:27 +0200 Subject: [PATCH 2/8] [Notifier][Firebase] Modify FirebaseOptions target to work with the union field from new API New API changed the 'to' field into a union field with 3 different types. This adds backwards-compatible way to support targeting this union field in FirebaseOptions. --- .../Bridge/Firebase/FirebaseOptions.php | 13 +++++------ .../Notifier/Bridge/Firebase/TargetType.php | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 src/Symfony/Component/Notifier/Bridge/Firebase/TargetType.php diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php index 1b3c67cf1da3a..bb04317d02820 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php @@ -15,20 +15,19 @@ /** * @author Jeroen Spee + * @author Vojtech Smejkal * - * @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages */ abstract class FirebaseOptions implements MessageOptionsInterface { - /** - * @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html#notification-payload-support - */ protected array $options; public function __construct( - private string $to, + private string $target, array $options, private array $data = [], + protected TargetType $targetType = TargetType::Topic, ) { $this->options = $options; } @@ -36,7 +35,7 @@ public function __construct( public function toArray(): array { return [ - 'to' => $this->to, + $this->targetType->value => $this->target, 'notification' => $this->options, 'data' => $this->data, ]; @@ -44,7 +43,7 @@ public function toArray(): array public function getRecipientId(): ?string { - return $this->to; + return '['.$this->targetType->value.']'.$this->target; } /** diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/TargetType.php b/src/Symfony/Component/Notifier/Bridge/Firebase/TargetType.php new file mode 100644 index 0000000000000..15d522052a37a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/TargetType.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Firebase; + +/** + * @author Vojtech Smejkal + */ +enum TargetType: string +{ + case Topic = 'topic'; + case Token = 'token'; + case Condition = 'condition'; +} From cdffea71d35a8ce159f02055af9e135b84e6ea8d Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sat, 12 Apr 2025 13:26:30 +0200 Subject: [PATCH 3/8] [Notifier][Firebase] Rework Firebase transport to be compatible with the new v1 API Backwards compatibility was maintained with the old DSN format. A deprecation is triggered when someone tries to create the transport with the old DSN format. Transport created using the old DSN will not be able to send messages and will throw exception for every sent message (this should be consistent with its previous behavior). --- .../Bridge/Firebase/FirebaseTransport.php | 93 +++++++++++++++---- .../Firebase/FirebaseTransportFactory.php | 26 +++++- 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php index 25e5864a913b8..dac8f89ae607c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Notifier\Bridge\Firebase; +use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\InvalidArgumentException; use Symfony\Component\Notifier\Exception\TransportException; use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; @@ -24,16 +25,32 @@ /** * @author Jeroen Spee + * @author Vojtech Smejkal + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send */ final class FirebaseTransport extends AbstractTransport { - protected const HOST = 'fcm.googleapis.com/fcm/send'; + protected const HOST = 'fcm.googleapis.com'; public function __construct( #[\SensitiveParameter] private string $token, + #[\SensitiveParameter] private string $projectId = '', + #[\SensitiveParameter] private string $clientEmail = '', + #[\SensitiveParameter] private string $privateKeyId = '', + #[\SensitiveParameter] private string $privateKey = '', ?HttpClientInterface $client = null, ?EventDispatcherInterface $dispatcher = null, ) { + if ('' !== $this->token) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'The $token parameter in "%s" is deprecated, use $projectId, $clientEmail, $privateKeyId and $privateKey instead.', + self::class, + ); + } + parent::__construct($client, $dispatcher); } @@ -53,48 +70,90 @@ protected function doSend(MessageInterface $message): SentMessage throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } - $endpoint = \sprintf('https://%s', $this->getEndpoint()); - $options = $message->getOptions()?->toArray() ?? []; - $options['to'] = $message->getRecipientId(); - - if (!$options['to']) { - throw new InvalidArgumentException(\sprintf('The "%s" transport required the "to" option to be set.', __CLASS__)); + if (!$this->projectId || !$this->privateKeyId || !$this->privateKey) { + throw new IncompleteDsnException(\sprintf('The "%s" transport requires project_id, private_key_id and private_key options to be specified in DSN.', self::class)); } + + $endpoint = \sprintf('https://%s/v1/projects/%s/messages:send', $this->getEndpoint(), $this->projectId); + + $options = $message->getOptions()?->toArray() ?? []; + $options['notification'] ??= []; $options['notification']['body'] = $message->getSubject(); $options['data'] ??= []; + if (!isset($options['token']) && !isset($options['topic']) && !isset($options['condition'])) { + throw new InvalidArgumentException(\sprintf('The "%s" transport requires the "token", "topic" or "condition" option to be set.', self::class)); + } + + // Send $response = $this->client->request('POST', $endpoint, [ - 'headers' => [ - 'Authorization' => \sprintf('key=%s', $this->token), - ], - 'json' => array_filter($options), + 'headers' => ['Authorization' => \sprintf('Bearer %s', $this->getJwt())], + 'json' => ['message' => $options], ]); try { $statusCode = $response->getStatusCode(); } catch (TransportExceptionInterface $e) { - throw new TransportException('Could not reach the remote Firebase server.', $response, 0, $e); + throw new TransportException('Sending message to Firebase failed.', $response, 0, $e); } $contentType = $response->getHeaders(false)['content-type'][0] ?? ''; - $jsonContents = str_starts_with($contentType, 'application/json') ? $response->toArray(false) : null; + $jsonContents = \str_starts_with($contentType, 'application/json') ? $response->toArray(false) : null; $errorMessage = null; - if ($jsonContents && isset($jsonContents['results'][0]['error'])) { - $errorMessage = $jsonContents['results'][0]['error']; + if (null !== $jsonContents && isset($jsonContents['error']['message'])) { + $errorMessage = $jsonContents['error']['message']; } elseif (200 !== $statusCode) { $errorMessage = $response->getContent(false); } if (null !== $errorMessage) { - throw new TransportException('Unable to post the Firebase message: '.$errorMessage, $response); + throw new TransportException(\sprintf('Firebase server responded with error "%s"', $errorMessage), $response); } $success = $response->toArray(false); $sentMessage = new SentMessage($message, (string) $this); - $sentMessage->setMessageId($success['results'][0]['message_id'] ?? ''); + $sentMessage->setMessageId($success['name'] ?? ''); return $sentMessage; } + + private function getJwt(): string + { + $time = time(); + $header = $this->base64UrlEncode([ + 'alg' => 'RS256', + 'typ' => 'JWT', + ]); + $payload = $this->base64UrlEncode([ + 'iss' => $this->clientEmail, + 'sub' => $this->clientEmail, + 'aud' => 'https://fcm.googleapis.com/', + 'iat' => $time, + 'exp' => $time + 3600, + 'kid' => $this->privateKeyId, + ]); + $key = \openssl_pkey_get_private($this->privateKey); + + if (false === $key) { + throw new InvalidArgumentException(\sprintf('The "%s" transport could not load private key from DSN. Is the key valid?', self::class)); + } + + \openssl_sign($header.'.'.$payload, $signature, $key, \OPENSSL_ALGO_SHA256); + + return $header.'.'.$payload.'.'.$this->base64UrlEncode($signature); + } + + /** + * @param string|array $data + */ + private function base64UrlEncode(string|array $data): string + { + if (\is_array($data)) { + $data = \json_encode($data, \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR); + } + + return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php index b7b4fe94fe9ec..8bc1e13bbbb38 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php @@ -11,12 +11,14 @@ namespace Symfony\Component\Notifier\Bridge\Firebase; +use Symfony\Component\Notifier\Exception\MissingRequiredOptionException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; /** * @author Jeroen Spee + * @author Vojtech Smejkal */ final class FirebaseTransportFactory extends AbstractTransportFactory { @@ -28,11 +30,29 @@ public function create(Dsn $dsn): FirebaseTransport throw new UnsupportedSchemeException($dsn, 'firebase', $this->getSupportedSchemes()); } - $token = \sprintf('%s:%s', $this->getUser($dsn), $this->getPassword($dsn)); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); - - return (new FirebaseTransport($token, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + $user = $this->getUser($dsn); + + try { + $projectId = $dsn->getRequiredOption('project_id'); + $privateKeyId = $dsn->getRequiredOption('private_key_id'); + $privateKey = $dsn->getRequiredOption('private_key'); + + return (new FirebaseTransport('', $projectId, $user, $privateKeyId, $privateKey, $this->client, $this->dispatcher)) + ->setHost($host) + ->setPort($port); + } catch (MissingRequiredOptionException) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using Firebase Notifier without project_id, private_key_id and private_key options is deprecated. Update your Firebase DSN.', + ); + + return (new FirebaseTransport('', '', '', '', '', $this->client, $this->dispatcher)) + ->setHost($host) + ->setPort($port); + } } protected function getSupportedSchemes(): array From 6136bc18183ca9020bee9fab7a294e43e22aabcc Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sat, 12 Apr 2025 13:28:59 +0200 Subject: [PATCH 4/8] [Notifier][Firebase] Update tests to reflect move to v1 API The private key used in tests was generated only for the purposes of these tests and is not connected to anything real. --- .../Tests/FirebaseTransportFactoryTest.php | 7 ++++--- .../Firebase/Tests/FirebaseTransportTest.php | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php index f1a73e1fa6357..f873f78351209 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php @@ -17,6 +17,7 @@ /** * @author Oskar Stark + * @author Vojtech Smejkal */ final class FirebaseTransportFactoryTest extends AbstractTransportFactoryTestCase { @@ -30,14 +31,15 @@ public function createFactory(): FirebaseTransportFactory public static function createProvider(): iterable { yield [ - 'firebase://host.test', - 'firebase://username:password@host.test', + 'firebase://fcm.googleapis.com', + 'firebase://firebase-adminsdk@iam.gserviceaccount.com@default?project_id=PROJECT_ID&private_key_id=PRIVATE_KEY_ID&private_key=PRIVATE_KEY', ]; } public static function supportsProvider(): iterable { yield [true, 'firebase://username:password@default']; + yield [true, 'firebase://firebase-adminsdk@iam.gserviceaccount.com@default?project_id=PROJECT_ID&private_key_id=PRIVATE_KEY_ID&private_key=PRIVATE_KEY']; yield [false, 'somethingElse://username:password@default']; } @@ -49,6 +51,5 @@ public static function unsupportedSchemeProvider(): iterable public static function incompleteDsnProvider(): iterable { yield ['firebase://:password@default']; - yield ['firebase://username@default']; } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php index 704e9b9212ee4..971faade57f01 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php @@ -25,17 +25,25 @@ /** * @author Oskar Stark + * @author Vojtech Smejkal */ final class FirebaseTransportTest extends TransportTestCase { public static function createTransport(?HttpClientInterface $client = null): FirebaseTransport { - return new FirebaseTransport('username:password', $client ?? new MockHttpClient()); + return new FirebaseTransport( + '', + 'test_project_id', + 'firebase-adminsdk-test@test-project.iam.gserviceaccount.com', + 'private_key_id', + "-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmU2f/GKCLuvw8NAl\nbqJW5RxhMrUrcampGQxz2F2OT3fqoyKBhAGzNhxbgPZYDeXp7WNNLTk9WLT7sDNM\ndjVUuQIDAQABAkAtOTX52QF4YAfKskxoj6E8oxuVPtabCCanCgJekHK7xDpYpYre\ncvxoPhw0c4McZiFoBOlr0TqyY9qpDWXDHLDFAiEAy9jNU/N438WrurUPuOfyqIYx\n25NJWQ7sgjfDJBMVHO8CIQDAhmed4Uih7QKZAMPeRayFeemdjcXNmN4wp/YjiLZ4\n1wIgaTWnnBnAnDYo0T+cMsI8QvCoEP0u0TFbrkXbiOX0cq8CIQCrg9GxrG75mt1y\nk2TrkuS0cLy4GQJ8PFDNxgSY+YWeNwIgavjv+v6MgyLrMTuZsAd67+5Z5axjdJL8\nbwLzq+QOXk8=\n-----END PRIVATE KEY-----\n", + $client ?? new MockHttpClient(), + ); } public static function toStringProvider(): iterable { - yield ['firebase://fcm.googleapis.com/fcm/send', self::createTransport()]; + yield ['firebase://fcm.googleapis.com', self::createTransport()]; } public static function supportedMessagesProvider(): iterable @@ -67,12 +75,12 @@ public function testSendWithErrorThrowsTransportException(ResponseInterface $res public static function sendWithErrorThrowsExceptionProvider(): iterable { yield [new MockResponse( - json_encode(['results' => [['error' => 'testErrorCode']]]), + json_encode(['error' => ['message' => 'testErrorCode']]), ['response_headers' => ['content-type' => ['application/json']], 'http_code' => 200] )]; yield [new MockResponse( - json_encode(['results' => [['error' => 'testErrorCode']]]), + json_encode(['error' => ['message' => 'testErrorCode']]), ['response_headers' => ['content-type' => ['application/json']], 'http_code' => 400] )]; } From 5fddcdde1681c1a64148a132e7fe6cfc91b4fd46 Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sat, 12 Apr 2025 14:22:16 +0200 Subject: [PATCH 5/8] [Notifier][Firebase] Rework FirebaseOptions to better reflect new API shape Shape of the endpoint input has changed with the new API. Separating options for messages based on the target platform no longer makes sense as all options for all platforms can now be set for the same message. AndroidNotification, IOSNotification and WebNotification were marked as deprecated and will be removed. FirebaseOptions is no longer abstract and should be used directly. --- .../Bridge/Firebase/FirebaseOptions.php | 174 ++++++++++++++++-- .../Notification/AndroidNotification.php | 58 +++--- .../Firebase/Notification/IOSNotification.php | 50 ++--- .../Firebase/Notification/WebNotification.php | 26 ++- 4 files changed, 234 insertions(+), 74 deletions(-) diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php index bb04317d02820..0f947f256f63c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php @@ -19,26 +19,26 @@ * * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages */ -abstract class FirebaseOptions implements MessageOptionsInterface +class FirebaseOptions implements MessageOptionsInterface { - protected array $options; - + /** + * @param array $data arbitrary meta-data (both keys and values must be a string) + */ public function __construct( private string $target, - array $options, - private array $data = [], + protected array $options = [], + array $data = [], protected TargetType $targetType = TargetType::Topic, ) { - $this->options = $options; + $this->options['data'] = $data; } public function toArray(): array { - return [ - $this->targetType->value => $this->target, - 'notification' => $this->options, - 'data' => $this->data, - ]; + $options = $this->options; + $options[$this->targetType->value] = $this->target; + + return $options; } public function getRecipientId(): ?string @@ -51,7 +51,7 @@ public function getRecipientId(): ?string */ public function title(string $title): static { - $this->options['title'] = $title; + $this->addNotificationOption('title', $title); return $this; } @@ -61,17 +61,163 @@ public function title(string $title): static */ public function body(string $body): static { - $this->options['body'] = $body; + $this->addNotificationOption('body', $body); + + return $this; + } + + /** + * @param string $image URL of an image that is going to be downloaded on the device and displayed in a notification + * + * @return $this + */ + public function image(string $image): static + { + $this->addNotificationOption('image', $image); return $this; } /** + * @param array $data + * * @return $this */ public function data(array $data): static { - $this->data = $data; + $this->options['data'] = $data; + + return $this; + } + + /** + * @param array $notification + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification + */ + public function notification(array $notification): static + { + $this->options['notification'] = $notification; + + return $this; + } + + /** + * @param array $android + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig + */ + public function android(array $android): static + { + $this->options['android'] = $android; + + return $this; + } + + /** + * @param array $webpush + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig + */ + public function webpush(array $webpush): static + { + $this->options['webpush'] = $webpush; + + return $this; + } + + /** + * @param array $apns + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig + */ + public function apns(array $apns): static + { + $this->options['apns'] = $apns; + + return $this; + } + + /** + * @param array $fcmOptions + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions + */ + public function fcmOptions(array $fcmOptions): static + { + $this->options['fcm_options'] = $fcmOptions; + + return $this; + } + + /** + * @return $this + */ + protected function addNotificationOption(string $key, mixed $value): static + { + $this->options['notification'] ??= []; + $this->options['notification'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addAndroidOption(string $key, mixed $value): static + { + $this->options['android'] ??= []; + $this->options['android']['notification'] ??= []; + $this->options['android']['notification'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addWebpushOption(string $key, mixed $value): static + { + $this->options['webpush'] ??= []; + $this->options['webpush']['notification'] ??= []; + $this->options['webpush']['notification'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addApnsOption(string $key, mixed $value): static + { + $this->options['apns'] ??= []; + $this->options['apns']['payload'] ??= []; + $this->options['apns']['payload']['aps'] ??= []; + $this->options['apns']['payload']['aps'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addApnsAlertOption(string $key, mixed $value): static + { + $this->options['apns'] ??= []; + $this->options['apns']['payload'] ??= []; + $this->options['apns']['payload']['aps'] ??= []; + $this->options['apns']['payload']['aps']['alert'] ??= []; + $this->options['apns']['payload']['aps']['alert'][$key] = $value; return $this; } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php index e5b193834de19..46cd8387ee397 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php @@ -12,17 +12,33 @@ namespace Symfony\Component\Notifier\Bridge\Firebase\Notification; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; final class AndroidNotification extends FirebaseOptions { + public function __construct( + string $target, + array $options = [], + array $data = [], + TargetType $targetType = TargetType::Topic, + ) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using %s class is deprecated, use %s instead.', + self::class, + FirebaseOptions::class, + ); + + parent::__construct($target, ['notification' => $options], $data, $targetType); + } + /** * @return $this */ public function channelId(string $channelId): static { - $this->options['android_channel_id'] = $channelId; - - return $this; + return $this->addAndroidOption('channel_id', $channelId); } /** @@ -30,9 +46,7 @@ public function channelId(string $channelId): static */ public function icon(string $icon): static { - $this->options['icon'] = $icon; - - return $this; + return $this->addAndroidOption('icon', $icon); } /** @@ -40,9 +54,7 @@ public function icon(string $icon): static */ public function sound(string $sound): static { - $this->options['sound'] = $sound; - - return $this; + return $this->addAndroidOption('sound', $sound); } /** @@ -50,9 +62,7 @@ public function sound(string $sound): static */ public function tag(string $tag): static { - $this->options['tag'] = $tag; - - return $this; + return $this->addAndroidOption('tag', $tag); } /** @@ -60,9 +70,7 @@ public function tag(string $tag): static */ public function color(string $color): static { - $this->options['color'] = $color; - - return $this; + return $this->addAndroidOption('color', $color); } /** @@ -70,9 +78,7 @@ public function color(string $color): static */ public function clickAction(string $clickAction): static { - $this->options['click_action'] = $clickAction; - - return $this; + return $this->addAndroidOption('click_action', $clickAction); } /** @@ -80,9 +86,7 @@ public function clickAction(string $clickAction): static */ public function bodyLocKey(string $bodyLocKey): static { - $this->options['body_loc_key'] = $bodyLocKey; - - return $this; + return $this->addAndroidOption('body_loc_key', $bodyLocKey); } /** @@ -92,9 +96,7 @@ public function bodyLocKey(string $bodyLocKey): static */ public function bodyLocArgs(array $bodyLocArgs): static { - $this->options['body_loc_args'] = $bodyLocArgs; - - return $this; + return $this->addAndroidOption('body_loc_args', $bodyLocArgs); } /** @@ -102,9 +104,7 @@ public function bodyLocArgs(array $bodyLocArgs): static */ public function titleLocKey(string $titleLocKey): static { - $this->options['title_loc_key'] = $titleLocKey; - - return $this; + return $this->addAndroidOption('title_loc_key', $titleLocKey); } /** @@ -114,8 +114,6 @@ public function titleLocKey(string $titleLocKey): static */ public function titleLocArgs(array $titleLocArgs): static { - $this->options['title_loc_args'] = $titleLocArgs; - - return $this; + return $this->addAndroidOption('title_loc_args', $titleLocArgs); } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php index 7ef34f11c452c..d3bd7b341f4a3 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php @@ -12,17 +12,33 @@ namespace Symfony\Component\Notifier\Bridge\Firebase\Notification; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; final class IOSNotification extends FirebaseOptions { + public function __construct( + string $target, + array $options = [], + array $data = [], + TargetType $targetType = TargetType::Topic, + ) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using %s class is deprecated, use %s instead.', + self::class, + FirebaseOptions::class, + ); + + parent::__construct($target, ['notification' => $options], $data, $targetType); + } + /** * @return $this */ public function sound(string $sound): static { - $this->options['sound'] = $sound; - - return $this; + return $this->addApnsOption('sound', $sound); } /** @@ -30,9 +46,7 @@ public function sound(string $sound): static */ public function badge(string $badge): static { - $this->options['badge'] = $badge; - - return $this; + return $this->addApnsOption('badge', $badge); } /** @@ -40,9 +54,7 @@ public function badge(string $badge): static */ public function clickAction(string $clickAction): static { - $this->options['click_action'] = $clickAction; - - return $this; + return $this->addApnsOption('category', $clickAction); } /** @@ -50,9 +62,7 @@ public function clickAction(string $clickAction): static */ public function subtitle(string $subtitle): static { - $this->options['subtitle'] = $subtitle; - - return $this; + return $this->addApnsAlertOption('subtitle', $subtitle); } /** @@ -60,9 +70,7 @@ public function subtitle(string $subtitle): static */ public function bodyLocKey(string $bodyLocKey): static { - $this->options['body_loc_key'] = $bodyLocKey; - - return $this; + return $this->addApnsAlertOption('body_loc_key', $bodyLocKey); } /** @@ -72,9 +80,7 @@ public function bodyLocKey(string $bodyLocKey): static */ public function bodyLocArgs(array $bodyLocArgs): static { - $this->options['body_loc_args'] = $bodyLocArgs; - - return $this; + return $this->addApnsAlertOption('body_loc_args', $bodyLocArgs); } /** @@ -82,9 +88,7 @@ public function bodyLocArgs(array $bodyLocArgs): static */ public function titleLocKey(string $titleLocKey): static { - $this->options['title_loc_key'] = $titleLocKey; - - return $this; + return $this->addApnsAlertOption('title_loc_key', $titleLocKey); } /** @@ -94,8 +98,6 @@ public function titleLocKey(string $titleLocKey): static */ public function titleLocArgs(array $titleLocArgs): static { - $this->options['title_loc_args'] = $titleLocArgs; - - return $this; + return $this->addApnsAlertOption('title_loc_args', $titleLocArgs); } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php index d925317f8e24a..0ce437344e308 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php @@ -12,17 +12,33 @@ namespace Symfony\Component\Notifier\Bridge\Firebase\Notification; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; final class WebNotification extends FirebaseOptions { + public function __construct( + string $target, + array $options = [], + array $data = [], + TargetType $targetType = TargetType::Topic, + ) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using %s class is deprecated, use %s instead.', + self::class, + FirebaseOptions::class, + ); + + parent::__construct($target, ['notification' => $options], $data, $targetType); + } + /** * @return $this */ public function icon(string $icon): static { - $this->options['icon'] = $icon; - - return $this; + return $this->addWebpushOption('icon', $icon); } /** @@ -30,8 +46,6 @@ public function icon(string $icon): static */ public function clickAction(string $clickAction): static { - $this->options['click_action'] = $clickAction; - - return $this; + return $this->addWebpushOption('click_action', $clickAction); } } From 390429fc42c09b3f78b01d2c39a230b6c4e45f2a Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sat, 12 Apr 2025 16:58:22 +0200 Subject: [PATCH 6/8] [Notifier][Firebase] Update README and CHANGELOG Reflects changes made by transferring to new v1 API. --- .../Notifier/Bridge/Firebase/CHANGELOG.md | 8 + .../Notifier/Bridge/Firebase/README.md | 166 ++++++++++++++++-- 2 files changed, 159 insertions(+), 15 deletions(-) diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md index 5b5417f3c604a..f26089a5d8aec 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +7.3 +--- + + * Changed to using the new v1 Firebase API + * [DEPRECATION] `AndroidNotification`, `IOSNotification` and `WebNotification` were deprecated and will be removed in 8.0, use `FirebaseOptions` instead. New Firebase API allows for all platform specific options to be specified for the message. Having options separated by platforms now limits usability. + * [DEPRECATION] Using DSN for firebase in the old format `firebase://username:password@default` was deprecated, use the new DSN format and specify the required options instead. Not specifying the options will throw error in 8.0. + * [DEPRECATION] `$token` param in `FirebaseTransport::__construct()` was marked as deprecated because the new Firebase API does not use standard token auth. The param will be removed in 8.0. + 5.3 --- diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/README.md b/src/Symfony/Component/Notifier/Bridge/Firebase/README.md index 7ff2c71575c88..95f1889846976 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/README.md +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/README.md @@ -7,32 +7,107 @@ DSN example ----------- ``` -FIREBASE_DSN=firebase://USERNAME:PASSWORD@default +FIREBASE_DSN=firebase://@default?project_id=&private_key_id=&private_key= +``` + +All 4 parameters are required. All 4 parameters are located inside your firebase json private key. + +IMPORTANT: Make sure that `PRIVATE_KEY` is safely url encoded. Get more information in [Notifier documentation](https://symfony.com/doc/current/notifier.html#chat-channel) or use the script below. + + +Getting credentials for your DSN +-------------------------------- + +Steps for getting your private key: + 1. Log into the [firebase console](https://console.firebase.google.com/). + 2. Click on your project + 3. Click on the gear icon next to "Project Overview" and click on "Project settings" + 4. Click on "Service accounts" tab + 5. Click on "Generate new private key" button at the bottom + 6. A JSON file with your private key will be downloaded + +The downloaded private key is a JSON file which should contain the following keys: + * `type` + * `project_id` + * `private_key_id` + * `private_key` + * `client_email` + * `client_id` + * `auth_uri` + * `token_uri` + * `auth_provider_x509_cert_url` + * `client_x509_cert_url` + * `universe_domain` + +You can then use the following script to convert your JSON private key to DSN format: + +```php +icon('myicon') - ->sound('default') - ->tag('myNotificationId') - ->color('#cccccc') - ->clickAction('OPEN_ACTIVITY_1') +// Specify options for Firebase +$firebaseOptions = (new FirebaseOptions('/topics/news')) + ->title('New message!') + ->body('This will overwrite the subject from ChatMessage object.') + ->image('https://path-to-your-image.png') + ->data([ + 'key1' => 'value1', + 'key2' => 'value2', + ]) // ... ; @@ -42,6 +117,67 @@ $chatMessage->options($androidOptions); $chatter->send($chatMessage); ``` +Firebase offers 3 different types of targets to send the message to: + * token + * topic + * condition + +How to specify different targets for firebase messages: + +```php +use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; + +// Use a topic as a target +$topicOptions = new FirebaseOptions('/topics/news', targetType: TargetType::Topic); + +// Use a token as a target +$tokenOptions = new FirebaseOptions('dU5e3nFJf9bE:APA91bH3Kd/exampleToken', targetType: TargetType::Token); + +// Use a condition as a target +$conditionOptions = new FirebaseOptions('\'news\' in topics', targetType: TargetType::Condition); +``` + +Firebase also allows specifying platform specific options. They can be specified as follows: + +```php +use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; + +$detailedOptions = (new FirebaseOptions('dU5e3nFJf9bE:APA91bH3Kd/exampleToken', tergetType: TargetType::Token)) + // Basic options + ->title('New message!') + ->data([ + 'key1' => 'value1', + 'key2' => 'value2', + ]) + // Android specific options + ->android([ + 'notification' => [ + 'color' => '#4538D5', + ], + ]) + // WebPush specific options + ->webpush([ + 'notification' => [ + 'icon' => 'https://path-to-an-icon.png', + ], + ]) + // APNS specific options + ->apns([ + 'payload' => [ + 'aps' => [ + 'sound' => 'default', + ], + ], + ]) + // ... + ; +``` + +For all available options please refer to the [firebase documentation](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages). + + Resources --------- From eccc04c8a08dad137ff87e8d202939e7cbd7b3ab Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sat, 12 Apr 2025 17:51:55 +0200 Subject: [PATCH 7/8] [Notifier][Firebase] Add dependency for symfony/deprecation-contracts --- src/Symfony/Component/Notifier/Bridge/Firebase/composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json index bda6193ce5dc8..be95dfe6d513c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json @@ -18,6 +18,7 @@ "require": { "php": ">=8.2", "ext-openssl": "*", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-client": "^6.4|^7.0", "symfony/notifier": "^7.2" }, From 828c14c954c13da3a1ddcf002f897a695416ba9b Mon Sep 17 00:00:00 2001 From: Vojtech Smejkal Date: Sun, 25 May 2025 17:00:08 +0200 Subject: [PATCH 8/8] Exclude Firebase Notifier Bridge from running on windows minimal tests Running tests there makes no sense since ext-openssl is needed. --- .github/workflows/windows.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 62ab3e5e6a3aa..dfc10d0c29f21 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -109,6 +109,7 @@ jobs: Copy c:\php\php.ini-min c:\php\php.ini Remove-Item -Path src\Symfony\Bridge\PhpUnit -Recurse + Remove-Item -Path src\Symfony\Component\Notifier\Bridge\Firebase -Recurse mv src\Symfony\Component\HttpClient\phpunit.xml.dist src\Symfony\Component\HttpClient\phpunit.xml php phpunit src\Symfony --exclude-group tty,benchmark,intl-data,network,transient-on-windows || ($x = 1) # HttpClient tests need to run separately, they block when run with other components' tests concurrently