From 540b6711d4da7a9fc7dd5fa293c8605b5d50ee10 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sat, 31 May 2025 08:09:21 +0700 Subject: [PATCH 1/2] [client] implement new methods for devices, webhooks, settings and logs management --- README.md | 53 +++-- src/Client.php | 289 ++++++++++++++++++++++++++- src/Domain/Device.php | 164 +++++++++++++++ src/Domain/LogEntry.php | 140 +++++++++++++ src/Domain/Message.php | 37 +++- src/Domain/MessageBuilder.php | 170 ++++++++++++++++ src/Domain/MessagesExportRequest.php | 59 ++++++ src/Domain/RecipientState.php | 2 +- src/Domain/Settings.php | 154 ++++++++++++++ src/Domain/SettingsBuilder.php | 120 +++++++++++ src/Domain/Webhook.php | 123 ++++++++++++ src/Enums/WebhookEvent.php | 61 ++++++ tests/ClientTest.php | 268 ++++++++++++++++++++++++- tests/Domain/MessageBuilderTest.php | 59 ++++++ tests/Domain/MessageTest.php | 2 +- tests/Domain/SettingsBuilderTest.php | 17 ++ tests/EncryptorTest.php | 2 + 17 files changed, 1691 insertions(+), 29 deletions(-) create mode 100644 src/Domain/Device.php create mode 100644 src/Domain/LogEntry.php create mode 100644 src/Domain/MessageBuilder.php create mode 100644 src/Domain/MessagesExportRequest.php create mode 100644 src/Domain/Settings.php create mode 100644 src/Domain/SettingsBuilder.php create mode 100644 src/Domain/Webhook.php create mode 100644 src/Enums/WebhookEvent.php create mode 100644 tests/Domain/MessageBuilderTest.php create mode 100644 tests/Domain/SettingsBuilderTest.php diff --git a/README.md b/README.md index 49659b4..cdf394a 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,11 @@ composer require capcom6/android-sms-gateway ## Usage -Here is a simple example of how to send a message using the library: +### Using the Builder Pattern +The library provides builder classes for creating `Message` and `Settings` objects with numerous optional fields. + +#### Creating a Message ```php setTtl(3600) + ->setSimNumber(1) + ->setWithDeliveryReport(true) + ->setPriority(100) + ->build(); try { - $messageState = $client->Send($message); + $messageState = $client->SendMessage($message); echo "Message sent with ID: " . $messageState->ID() . PHP_EOL; } catch (Exception $e) { echo "Error sending message: " . $e->getMessage() . PHP_EOL; @@ -48,7 +52,7 @@ try { } try { - $messageState = $client->GetState($messageState->ID()); + $messageState = $client->GetMessageState($messageState->ID()); echo "Message state: " . $messageState->State() . PHP_EOL; } catch (Exception $e) { echo "Error getting message state: " . $e->getMessage() . PHP_EOL; @@ -60,12 +64,35 @@ try { The `Client` is used for sending SMS messages in plain text, but can also be used for sending encrypted messages by providing an `Encryptor`. -### Methods +### Message Methods + +* `Send(Message $message)` (deprecated): Send a new SMS message. +* `SendMessage(Message $message)`: Send a new SMS message. +* `GetState(string $id)` (deprecated): Retrieve the state of a previously sent message by its ID. +* `GetMessageState(string $id)`: Retrieve the state of a previously sent message by its ID. + +### Device Methods + +* `ListDevices()`: List all registered devices. +* `RemoveDevice(string $id)`: Remove a device by ID. + +### System Methods + +* `HealthCheck()`: Check system health. +* `RequestInboxExport(object $request)`: Request inbox messages export. +* `GetLogs(?string $from = null, ?string $to = null)`: Get logs within a specified time range. + +### Settings Methods + +* `GetSettings()`: Get user settings. +* `UpdateSettings(object $settings)`: Update user settings. +* `PatchSettings(object $settings)`: Partially update user settings. -The `Client` class has the following methods: +### Webhook Methods -* `Send(Message $message)`: Send a new SMS message. -* `GetState(string $id)`: Retrieve the state of a previously sent message by its ID. +* `ListWebhooks()`: List all registered webhooks. +* `RegisterWebhook(object $webhook)`: Register a new webhook. +* `DeleteWebhook(string $id)`: Delete a webhook by ID. # Contributing diff --git a/src/Client.php b/src/Client.php index 06a9b13..324614b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -4,6 +4,11 @@ use AndroidSmsGateway\Domain\Message; use AndroidSmsGateway\Domain\MessageState; +use AndroidSmsGateway\Domain\Device; +use AndroidSmsGateway\Domain\LogEntry; +use AndroidSmsGateway\Domain\Webhook; +use AndroidSmsGateway\Domain\MessagesExportRequest; +use AndroidSmsGateway\Domain\Settings; use AndroidSmsGateway\Exceptions\HttpException; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; @@ -41,8 +46,37 @@ public function __construct( $this->streamFactory = Psr17FactoryDiscovery::findStreamFactory(); } - public function Send(Message $message): MessageState { + /** + * Send a message (deprecated method) + * + * @param Message $message + * @param bool $skipPhoneValidation + * @return MessageState + */ + public function Send(Message $message, bool $skipPhoneValidation = false): MessageState { + trigger_error( + 'The Send method is deprecated. Use the new SendMessage method instead.', + E_USER_DEPRECATED + ); + + return $this->SendMessage($message, $skipPhoneValidation); + } + + /** + * Send a message + * + * @param Message $message + * @param bool $skipPhoneValidation + * @return MessageState + */ + public function SendMessage(Message $message, bool $skipPhoneValidation = false): MessageState { $path = '/messages'; + $queryParams = []; + if ($skipPhoneValidation) { + $queryParams['skipPhoneValidation'] = 'true'; + } + + $queryString = empty($queryParams) ? '' : '?' . http_build_query($queryParams); if (isset($this->encryptor)) { $message = $message->Encrypt($this->encryptor); @@ -50,7 +84,7 @@ public function Send(Message $message): MessageState { $response = $this->sendRequest( 'POST', - $path, + $path . $queryString, $message ); if (!is_object($response)) { @@ -66,7 +100,28 @@ public function Send(Message $message): MessageState { return $state; } + /** + * Get message state by ID (deprecated method) + * + * @param string $id + * @return MessageState + */ public function GetState(string $id): MessageState { + trigger_error( + 'The GetState method is deprecated. Use the new GetMessageState method instead.', + E_USER_DEPRECATED + ); + + return $this->GetMessageState($id); + } + + /** + * Get message state by ID + * + * @param string $id + * @return MessageState + */ + public function GetMessageState(string $id): MessageState { $path = '/messages/' . $id; $response = $this->sendRequest( @@ -86,6 +141,236 @@ public function GetState(string $id): MessageState { return $state; } + + /** + * List all devices + * + * @return array + */ + public function ListDevices(): array { + $path = '/devices'; + + $response = $this->sendRequest( + 'GET', + $path + ); + if (!is_array($response)) { + throw new RuntimeException('Invalid response'); + } + + return array_map( + static fn($obj) => Device::FromObject($obj), + $response + ); + } + + /** + * Remove a device by ID + * + * @param string $id + * @return void + */ + public function RemoveDevice(string $id): void { + $path = '/devices/' . $id; + + $this->sendRequest( + 'DELETE', + $path + ); + } + + /** + * Check system health + * + * @return object + */ + public function HealthCheck(): object { + $path = '/health'; + + $response = $this->sendRequest( + 'GET', + $path + ); + if (!is_object($response)) { + throw new RuntimeException('Invalid response'); + } + + return $response; + } + + /** + * Request inbox messages export + * + * @param MessagesExportRequest $request + * @return object + */ + public function RequestInboxExport(MessagesExportRequest $request): object { + $path = '/inbox/export'; + + $response = $this->sendRequest( + 'POST', + $path, + $request + ); + if (!is_object($response)) { + throw new RuntimeException('Invalid response'); + } + + return $response; + } + + /** + * Get logs within a specified time range + * + * @param string|null $from + * @param string|null $to + * @return array + */ + public function GetLogs(?string $from = null, ?string $to = null): array { + $path = '/logs'; + $queryParams = []; + if ($from !== null) { + $queryParams['from'] = $from; + } + if ($to !== null) { + $queryParams['to'] = $to; + } + + $queryString = empty($queryParams) ? '' : '?' . http_build_query($queryParams); + + $response = $this->sendRequest( + 'GET', + $path . $queryString + ); + if (!is_array($response)) { + throw new RuntimeException('Invalid response'); + } + + return array_map( + static fn($obj) => LogEntry::FromObject($obj), + $response + ); + } + + /** + * Get user settings + * + * @return Settings + */ + public function GetSettings(): Settings { + $path = '/settings'; + + $response = $this->sendRequest( + 'GET', + $path + ); + if (!is_object($response)) { + throw new RuntimeException('Invalid response'); + } + + return Settings::FromObject($response); + } + + /** + * Update user settings + * + * @param Settings $settings + * @return Settings + */ + public function ReplaceSettings(Settings $settings): Settings { + $path = '/settings'; + + $response = $this->sendRequest( + 'PUT', + $path, + $settings + ); + if (!is_object($response)) { + throw new RuntimeException('Invalid response'); + } + + return Settings::FromObject($response); + } + + /** + * Partially update user settings + * + * @param Settings $settings + * @return Settings + */ + public function PatchSettings(Settings $settings): Settings { + $path = '/settings'; + + $response = $this->sendRequest( + 'PATCH', + $path, + $settings + ); + if (!is_object($response)) { + throw new RuntimeException('Invalid response'); + } + + return Settings::FromObject($response); + } + + /** + * List all webhooks + * + * @return array + */ + public function ListWebhooks(): array { + $path = '/webhooks'; + + $response = $this->sendRequest( + 'GET', + $path + ); + if (!is_array($response)) { + throw new RuntimeException('Invalid response'); + } + + return array_map( + static fn($obj) => Webhook::FromObject($obj), + $response + ); + } + + /** + * Register a webhook + * + * @param Webhook $webhook + * @return Webhook + */ + public function RegisterWebhook(Webhook $webhook): Webhook { + $path = '/webhooks'; + + $response = $this->sendRequest( + 'POST', + $path, + $webhook + ); + if (!is_object($response)) { + throw new RuntimeException('Invalid response'); + } + + return Webhook::FromObject($response); + } + + /** + * Delete a webhook by ID + * + * @param string $id + * @return void + */ + public function DeleteWebhook(string $id): void { + $path = '/webhooks/' . $id; + + $this->sendRequest( + 'DELETE', + $path + ); + } + /** * @param \AndroidSmsGateway\Interfaces\SerializableInterface|null $payload * @throws \Http\Client\Exception\HttpException diff --git a/src/Domain/Device.php b/src/Domain/Device.php new file mode 100644 index 0000000..fb0b9ad --- /dev/null +++ b/src/Domain/Device.php @@ -0,0 +1,164 @@ +id = $id; + $this->name = $name; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + $this->deletedAt = $deletedAt; + $this->lastSeen = $lastSeen; + } + + /** + * @param object $obj + * @return self + */ + public static function FromObject(object $obj): self { + return new self( + $obj->id, + $obj->name, + $obj->createdAt, + $obj->updatedAt, + $obj->deletedAt ?? null, + $obj->lastSeen ?? null + ); + } + + /** + * @return object + */ + public function ToObject(): object { + $obj = (object) [ + 'id' => $this->id, + 'name' => $this->name, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + + if ($this->deletedAt !== null) { + $obj->deletedAt = $this->deletedAt; + } + + if ($this->lastSeen !== null) { + $obj->lastSeen = $this->lastSeen; + } + + return $obj; + } + + /** + * Get device ID + * @return string + */ + public function ID(): string { + return $this->id; + } + + /** + * Get device name + * @return string + */ + public function Name(): string { + return $this->name; + } + + /** + * Get created at timestamp + * @return string + */ + public function CreatedAt(): string { + return $this->createdAt; + } + + /** + * Get updated at timestamp + * @return string + */ + public function UpdatedAt(): string { + return $this->updatedAt; + } + + /** + * Get deleted at timestamp + * @return string|null + */ + public function DeletedAt(): ?string { + return $this->deletedAt; + } + + /** + * Get last seen timestamp + * @return string|null + */ + public function LastSeen(): ?string { + return $this->lastSeen; + } + + /** + * @return string + */ + public function __toString(): string { + return sprintf( + '[id] %s [name] %s [created at] %s [updated at] %s [deleted at] %s [last seen] %s', + $this->id, + $this->name, + $this->createdAt, + $this->updatedAt, + $this->deletedAt ?? '', + $this->lastSeen ?? '' + ); + } +} \ No newline at end of file diff --git a/src/Domain/LogEntry.php b/src/Domain/LogEntry.php new file mode 100644 index 0000000..7f76f2c --- /dev/null +++ b/src/Domain/LogEntry.php @@ -0,0 +1,140 @@ +id = $id; + $this->message = $message; + $this->module = $module; + $this->priority = $priority; + $this->createdAt = $createdAt; + $this->context = $context; + } + + /** + * @param object $obj + * @return self + */ + public static function FromObject(object $obj): self { + return new self( + $obj->id, + $obj->message, + $obj->module, + $obj->priority, + $obj->createdAt, + $obj->context ?? null + ); + } + + /** + * @return object + */ + public function ToObject(): object { + $obj = (object) [ + 'id' => $this->id, + 'message' => $this->message, + 'module' => $this->module, + 'priority' => $this->priority, + 'createdAt' => $this->createdAt, + ]; + + if ($this->context !== null) { + $obj->context = $this->context; + } + + return $obj; + } + + /** + * @return int + */ + public function ID(): int { + return $this->id; + } + + /** + * @return string + */ + public function Message(): string { + return $this->message; + } + + /** + * @return string + */ + public function Module(): string { + return $this->module; + } + + /** + * @return string + */ + public function Priority(): string { + return $this->priority; + } + + /** + * @return object|null + */ + public function Context(): ?object { + return $this->context; + } + + /** + * @return string + */ + public function CreatedAt(): string { + return $this->createdAt; + } +} \ No newline at end of file diff --git a/src/Domain/Message.php b/src/Domain/Message.php index 2e7be43..12dbec5 100644 --- a/src/Domain/Message.php +++ b/src/Domain/Message.php @@ -40,6 +40,14 @@ class Message implements SerializableInterface { * @var array */ private array $phoneNumbers; + /** + * Message priority + */ + private ?int $priority = null; + /** + * Valid until timestamp + */ + private ?string $validUntil; /** * @param array $phoneNumbers @@ -50,8 +58,14 @@ public function __construct( ?string $id = null, ?int $ttl = null, ?int $simNumber = null, - bool $withDeliveryReport = true + bool $withDeliveryReport = true, + ?int $priority = null, + ?string $validUntil = null ) { + if ($ttl !== null && $validUntil !== null) { + throw new \InvalidArgumentException('validUntil and ttl cannot be set at the same time'); + } + $this->id = $id; $this->message = $message; $this->ttl = $ttl; @@ -59,6 +73,8 @@ public function __construct( $this->withDeliveryReport = $withDeliveryReport; $this->phoneNumbers = $phoneNumbers; $this->isEncrypted = false; + $this->priority = $priority; + $this->validUntil = $validUntil; } public function Encrypt(Encryptor $encryptor): self { @@ -76,14 +92,27 @@ public function Encrypt(Encryptor $encryptor): self { } public function ToObject(): object { - return (object) [ + $obj = (object) [ 'id' => $this->id, 'message' => $this->message, - 'ttl' => $this->ttl, 'simNumber' => $this->simNumber, 'withDeliveryReport' => $this->withDeliveryReport, 'isEncrypted' => $this->isEncrypted, - 'phoneNumbers' => $this->phoneNumbers + 'phoneNumbers' => $this->phoneNumbers, ]; + + if ($this->priority !== null) { + $obj->priority = $this->priority; + } + + if ($this->ttl !== null) { + $obj->ttl = $this->ttl; + } + + if ($this->validUntil !== null) { + $obj->validUntil = $this->validUntil; + } + + return $obj; } } \ No newline at end of file diff --git a/src/Domain/MessageBuilder.php b/src/Domain/MessageBuilder.php new file mode 100644 index 0000000..c2c6d4b --- /dev/null +++ b/src/Domain/MessageBuilder.php @@ -0,0 +1,170 @@ + + */ + private array $phoneNumbers; + + /** + * @var int|null + */ + private ?int $priority = null; + + /** + * @var string|null + */ + private ?string $validUntil = null; + + /** + * @param string $message + * @param array $phoneNumbers + */ + public function __construct(string $message, array $phoneNumbers) { + $this->message = $message; + $this->phoneNumbers = $phoneNumbers; + } + + /** + * Set message ID + * + * @param string|null $id + * @return $this + */ + public function setId(?string $id): self { + $this->id = $id; + return $this; + } + + /** + * Set message text + * + * @param string $message + * @return $this + */ + public function setMessage(string $message): self { + $this->message = $message; + return $this; + } + + /** + * Set time to live in seconds + * + * @param int|null $ttl + * @return $this + */ + public function setTtl(?int $ttl): self { + $this->ttl = $ttl; + return $this; + } + + /** + * Set SIM card number + * + * @param int|null $simNumber + * @return $this + */ + public function setSimNumber(?int $simNumber): self { + $this->simNumber = $simNumber; + return $this; + } + + /** + * Set delivery report flag + * + * @param bool $withDeliveryReport + * @return $this + */ + public function setWithDeliveryReport(bool $withDeliveryReport): self { + $this->withDeliveryReport = $withDeliveryReport; + return $this; + } + /** + * Set phone numbers + * + * @param array $phoneNumbers + * @return $this + */ + public function setPhoneNumbers(array $phoneNumbers): self { + $this->phoneNumbers = $phoneNumbers; + return $this; + } + + /** + * Set message priority + * + * @param int|null $priority + * @return $this + */ + public function setPriority(?int $priority): self { + $this->priority = $priority; + return $this; + } + + /** + * Set valid until timestamp + * + * @param string|null $validUntil + * @return $this + */ + public function setValidUntil(?string $validUntil): self { + $this->validUntil = $validUntil; + return $this; + } + + /** + * Build the Message object + * + * @return Message + * @throws InvalidArgumentException + */ + public function build(): Message { + if ($this->ttl !== null && $this->validUntil !== null) { + throw new InvalidArgumentException('validUntil and ttl cannot be set at the same time'); + } + + return new Message( + $this->message, + $this->phoneNumbers, + $this->id, + $this->ttl, + $this->simNumber, + $this->withDeliveryReport, + $this->priority, + $this->validUntil + ); + } +} \ No newline at end of file diff --git a/src/Domain/MessagesExportRequest.php b/src/Domain/MessagesExportRequest.php new file mode 100644 index 0000000..ddb342a --- /dev/null +++ b/src/Domain/MessagesExportRequest.php @@ -0,0 +1,59 @@ +deviceId = $deviceId; + $this->since = $since; + $this->until = $until; + } + + /** + * @param object $obj + * @return self + */ + public static function FromObject(object $obj): self { + return new self( + $obj->deviceId, + $obj->since, + $obj->until + ); + } + + /** + * @return object + */ + public function ToObject(): object { + return (object) [ + 'deviceId' => $this->deviceId, + 'since' => $this->since, + 'until' => $this->until, + ]; + } +} \ No newline at end of file diff --git a/src/Domain/RecipientState.php b/src/Domain/RecipientState.php index c223cd1..603cb11 100644 --- a/src/Domain/RecipientState.php +++ b/src/Domain/RecipientState.php @@ -10,7 +10,7 @@ */ class RecipientState { /** - * Recipient's phone number + * Recipient's phone number (or hash) */ protected string $phoneNumber; /** diff --git a/src/Domain/Settings.php b/src/Domain/Settings.php new file mode 100644 index 0000000..a1d1b0c --- /dev/null +++ b/src/Domain/Settings.php @@ -0,0 +1,154 @@ +encryption = $encryption; + $this->gateway = $gateway; + $this->logs = $logs; + $this->messages = $messages; + $this->ping = $ping; + $this->webhooks = $webhooks; + } + + /** + * @return object|null + */ + public function Encryption(): ?object { + return $this->encryption; + } + + /** + * @return object|null + */ + public function Gateway(): ?object { + return $this->gateway; + } + + /** + * @return object|null + */ + public function Logs(): ?object { + return $this->logs; + } + + /** + * @return object|null + */ + public function Messages(): ?object { + return $this->messages; + } + + /** + * @return object|null + */ + public function Ping(): ?object { + return $this->ping; + } + + /** + * @return object|null + */ + public function Webhooks(): ?object { + return $this->webhooks; + } + + /** + * @param object $obj + * @return self + */ + public static function FromObject(object $obj): self { + return new self( + $obj->encryption ?? null, + $obj->gateway ?? null, + $obj->logs ?? null, + $obj->messages ?? null, + $obj->ping ?? null, + $obj->webhooks ?? null + ); + } + + /** + * @return object + */ + public function ToObject(): object { + $obj = new \stdClass(); + + if ($this->encryption !== null) { + $obj->encryption = $this->encryption; + } + + if ($this->gateway !== null) { + $obj->gateway = $this->gateway; + } + + if ($this->logs !== null) { + $obj->logs = $this->logs; + } + + if ($this->messages !== null) { + $obj->messages = $this->messages; + } + + if ($this->ping !== null) { + $obj->ping = $this->ping; + } + + if ($this->webhooks !== null) { + $obj->webhooks = $this->webhooks; + } + + return $obj; + } +} \ No newline at end of file diff --git a/src/Domain/SettingsBuilder.php b/src/Domain/SettingsBuilder.php new file mode 100644 index 0000000..78232a6 --- /dev/null +++ b/src/Domain/SettingsBuilder.php @@ -0,0 +1,120 @@ +encryption = $encryption; + return $this; + } + + /** + * Set gateway settings + * + * @param object|null $gateway + * @return $this + */ + public function setGateway(?object $gateway): self { + $this->gateway = $gateway; + return $this; + } + + /** + * Set logs settings + * + * @param object|null $logs + * @return $this + */ + public function setLogs(?object $logs): self { + $this->logs = $logs; + return $this; + } + + /** + * Set messages settings + * + * @param object|null $messages + * @return $this + */ + public function setMessages(?object $messages): self { + $this->messages = $messages; + return $this; + } + + /** + * Set ping settings + * + * @param object|null $ping + * @return $this + */ + public function setPing(?object $ping): self { + $this->ping = $ping; + return $this; + } + + /** + * Set webhooks settings + * + * @param object|null $webhooks + * @return $this + */ + public function setWebhooks(?object $webhooks): self { + $this->webhooks = $webhooks; + return $this; + } + + /** + * Build the Settings object + * + * @return Settings + */ + public function build(): Settings { + return new Settings( + $this->encryption, + $this->gateway, + $this->logs, + $this->messages, + $this->ping, + $this->webhooks + ); + } +} \ No newline at end of file diff --git a/src/Domain/Webhook.php b/src/Domain/Webhook.php new file mode 100644 index 0000000..3791dda --- /dev/null +++ b/src/Domain/Webhook.php @@ -0,0 +1,123 @@ +id = $id; + $this->event = $event; + $this->url = $url; + $this->deviceId = $deviceId; + } + + /** + * @param object $obj + * @return self + */ + public static function FromObject(object $obj): self { + return new self( + WebhookEvent::FromValue($obj->event), + $obj->url, + $obj->id ?? null, + $obj->deviceId ?? null + ); + } + + /** + * @return object + */ + public function ToObject(): object { + $obj = (object) [ + 'event' => $this->event->Value(), + 'url' => $this->url, + ]; + + if ($this->id !== null) { + $obj->id = $this->id; + } + + if ($this->deviceId !== null) { + $obj->deviceId = $this->deviceId; + } + + return $obj; + } + + /** + * @return string|null + */ + public function ID(): ?string { + return $this->id; + } + + /** + * @return WebhookEvent + */ + public function Event(): WebhookEvent { + return $this->event; + } + + /** + * @return string + */ + public function Url(): string { + return $this->url; + } + + /** + * @return string|null + */ + public function DeviceId(): ?string { + return $this->deviceId; + } + + /** + * @return string + */ + public function __toString(): string { + return sprintf( + '[id] %s [event] %s [url] %s [device id] %s', + $this->id, + $this->event->Value(), + $this->url, + $this->deviceId + ); + } +} \ No newline at end of file diff --git a/src/Enums/WebhookEvent.php b/src/Enums/WebhookEvent.php new file mode 100644 index 0000000..83dfd45 --- /dev/null +++ b/src/Enums/WebhookEvent.php @@ -0,0 +1,61 @@ +value = $value; + } + + public static function SMS_RECEIVED(): self { + return new self(self::SMS_RECEIVED); + } + + public static function SMS_SENT(): self { + return new self(self::SMS_SENT); + } + + public static function SMS_DELIVERED(): self { + return new self(self::SMS_DELIVERED); + } + + public static function SMS_FAILED(): self { + return new self(self::SMS_FAILED); + } + + public static function SYSTEM_PING(): self { + return new self(self::SYSTEM_PING); + } + + public static function FromValue(string $value): self { + return new self($value); + } + + public function Value(): string { + return $this->value; + } + + public function __toString(): string { + return $this->value; + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 62892af..3121b9b 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -1,12 +1,16 @@ client = new Client(self::MOCK_LOGIN, self::MOCK_PASSWORD, Client::DEFAULT_URL, $this->mockClient); } - public function testSend(): void { + public function testSendMessage(): void { $messageMock = $this->createMock(Message::class); $messageMock->method('ToObject')->willReturn((object) []); @@ -43,7 +47,7 @@ public function testSend(): void { $this->mockClient->addResponse($responseMock); - $messageState = $this->client->Send($messageMock); + $messageState = $this->client->SendMessage($messageMock); $req = $this->mockClient->getLastRequest(); $this->assertEquals('POST', $req->getMethod()); $this->assertEquals('/3rdparty/v1/messages', $req->getUri()->getPath()); @@ -64,7 +68,7 @@ public function testSend(): void { $this->assertInstanceOf(MessageState::class, $messageState); } - public function testGetState(): void { + public function testGetMessageState(): void { $responseMock = self::mockResponse( '{"id":"123","state":"Delivered","recipients":[{"phoneNumber":"+79000000000","state":"Delivered"}]}', 200, @@ -73,7 +77,7 @@ public function testGetState(): void { $this->mockClient->addResponse($responseMock); - $messageState = $this->client->GetState('123'); + $messageState = $this->client->GetMessageState('123'); $req = $this->mockClient->getLastRequest(); $this->assertEquals('GET', $req->getMethod()); $this->assertEquals('/3rdparty/v1/messages/123', $req->getUri()->getPath()); @@ -104,11 +108,11 @@ public function testServer(): void { $message = new Message(date('Y-m-d H:i:s'), [$phoneNumber]); - $messageState = $client->Send($message); + $messageState = $client->SendMessage($message); $this->assertInstanceOf(MessageState::class, $messageState); $this->assertEquals(ProcessState::PENDING(), $messageState->State()); - $messageState2 = $client->GetState($messageState->ID()); + $messageState2 = $client->GetMessageState($messageState->ID()); $this->assertInstanceOf(MessageState::class, $messageState2); $this->assertEquals($messageState->ID(), $messageState2->ID()); } @@ -132,6 +136,254 @@ private static function mockResponse(string $body, int $code = 200, array $heade return $response; } + public function testListDevices(): void { + $responseMock = self::mockResponse( + '[{"id":"123","name":"Test Device","createdAt":"2020-01-01T00:00:00Z","updatedAt":"2020-01-01T00:00:00Z"}]', + 200, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $devices = $this->client->ListDevices(); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('GET', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/devices', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertCount(1, $devices); + $this->assertEquals('123', $devices[0]->ID()); + } + + public function testRemoveDevice(): void { + $responseMock = self::mockResponse('', 204); + + $this->mockClient->addResponse($responseMock); + + $this->client->RemoveDevice('123'); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('DELETE', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/devices/123', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + } + + public function testHealthCheck(): void { + $responseMock = self::mockResponse( + '{"status":"pass","checks":{},"releaseId":1,"version":"1.0.0"}', + 200, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $health = $this->client->HealthCheck(); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('GET', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/health', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertEquals('pass', $health->status); + } + + public function testRequestInboxExport(): void { + $request = new MessagesExportRequest('123', '2020-01-01T00:00:00Z', '2020-01-02T00:00:00Z'); + + $responseMock = self::mockResponse( + '{"status":"accepted"}', + 202, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $result = $this->client->RequestInboxExport($request); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('POST', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/inbox/export', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertEquals('accepted', $result->status); + } + + public function testGetLogs(): void { + $responseMock = self::mockResponse( + '[{"id":1,"message":"Test log","module":"test","priority":"INFO","createdAt":"2020-01-01T00:00:00Z"}]', + 200, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $logs = $this->client->GetLogs('2020-01-01T00:00:00Z', '2020-01-02T00:00:00Z'); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('GET', $req->getMethod()); + $uri = $req->getUri()->getPath(); + $this->assertEquals('/3rdparty/v1/logs', $uri); + $query = $req->getUri()->getQuery(); + $this->assertStringContainsString('from=2020-01-01T00%3A00%3A00Z', $query); + $this->assertStringContainsString('to=2020-01-02T00%3A00%3A00Z', $query); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertCount(1, $logs); + $this->assertEquals(1, $logs[0]->ID()); + } + + public function testGetSettings(): void { + $responseMock = self::mockResponse( + '{"encryption":{},"gateway":{},"logs":{},"messages":{},"ping":{},"webhooks":{}}', + 200, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $settings = $this->client->GetSettings(); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('GET', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/settings', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertObjectHasProperty('encryption', $settings); + } + + public function testUpdateSettings(): void { + $settings = new Settings( + new \stdClass(), + new \stdClass(), + new \stdClass(), + new \stdClass(), + new \stdClass(), + new \stdClass() + ); + + $responseMock = self::mockResponse( + '{"logs":{"lifetime_days":1}}', + 200, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $result = $this->client->ReplaceSettings($settings); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('PUT', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/settings', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertEquals(1, $result->Logs()->lifetime_days); + } + + public function testPatchSettings(): void { + $settings = new Settings( + new \stdClass(), + null, + null, + null, + null, + null + ); + + $responseMock = self::mockResponse( + '{"logs":{"lifetime_days":1}}', + 200, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $result = $this->client->PatchSettings($settings); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('PATCH', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/settings', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertEquals(1, $result->Logs()->lifetime_days); + } + + public function testListWebhooks(): void { + $responseMock = self::mockResponse( + '[{"id":"123","event":"sms:received","url":"https://example.com/webhook"}]', + 200, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $webhooks = $this->client->ListWebhooks(); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('GET', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/webhooks', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertCount(1, $webhooks); + $this->assertEquals('123', $webhooks[0]->ID()); + } + + public function testRegisterWebhook(): void { + $webhook = new Webhook(WebhookEvent::SMS_RECEIVED(), 'https://example.com/webhook'); + + $responseMock = self::mockResponse( + '{"id":"123","event":"sms:received","url":"https://example.com/webhook"}', + 201, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $result = $this->client->RegisterWebhook($webhook); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('POST', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/webhooks', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + + $this->assertEquals('123', $result->ID()); + } + + public function testDeleteWebhook(): void { + $responseMock = self::mockResponse('', 204); + + $this->mockClient->addResponse($responseMock); + + $this->client->DeleteWebhook('123'); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('DELETE', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/webhooks/123', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + } + public const MOCK_LOGIN = 'login'; public const MOCK_PASSWORD = 'password'; } \ No newline at end of file diff --git a/tests/Domain/MessageBuilderTest.php b/tests/Domain/MessageBuilderTest.php new file mode 100644 index 0000000..c3b0245 --- /dev/null +++ b/tests/Domain/MessageBuilderTest.php @@ -0,0 +1,59 @@ +build(); + + $this->assertInstanceOf(Message::class, $message); + $this->assertEquals('Test message', $message->ToObject()->message); + $this->assertEquals($phoneNumbers, $message->ToObject()->phoneNumbers); + $this->assertTrue($message->ToObject()->withDeliveryReport); + $this->assertFalse($message->ToObject()->isEncrypted); + } + + public function testBuildWithAllParameters(): void { + $phoneNumbers = ['+1234567890']; + $message = (new MessageBuilder('Test message', $phoneNumbers)) + ->setId('123') + ->setTtl(3600) + ->setSimNumber(1) + ->setWithDeliveryReport(false) + ->setPriority(1) + ->build(); + + $obj = $message->ToObject(); + $this->assertEquals('123', $obj->id); + $this->assertEquals(3600, $obj->ttl); + $this->assertEquals(1, $obj->simNumber); + $this->assertFalse($obj->withDeliveryReport); + $this->assertEquals(1, $obj->priority); + } + + public function testBuildWithInvalidParameters(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('validUntil and ttl cannot be set at the same time'); + + $phoneNumbers = ['+1234567890']; + (new MessageBuilder('Test message', $phoneNumbers)) + ->setTtl(3600) + ->setValidUntil('2025-12-31T23:59:59Z') + ->build(); + } + + public function testMethodChaining(): void { + $phoneNumbers = ['+1234567890']; + $builder = new MessageBuilder('Test message', $phoneNumbers); + + $this->assertInstanceOf(MessageBuilder::class, $builder->setTtl(3600)); + $this->assertInstanceOf(MessageBuilder::class, $builder->setSimNumber(1)); + $this->assertInstanceOf(MessageBuilder::class, $builder->setWithDeliveryReport(false)); + } +} \ No newline at end of file diff --git a/tests/Domain/MessageTest.php b/tests/Domain/MessageTest.php index 6bb7e96..1eb0db2 100644 --- a/tests/Domain/MessageTest.php +++ b/tests/Domain/MessageTest.php @@ -49,8 +49,8 @@ public function testDefaultsWithNullParameters(): void { $serialized = $message->ToObject(); $this->assertNull($serialized->id); - $this->assertNull($serialized->ttl); $this->assertNull($serialized->simNumber); $this->assertTrue($serialized->withDeliveryReport); + $this->assertFalse($serialized->isEncrypted); } } \ No newline at end of file diff --git a/tests/Domain/SettingsBuilderTest.php b/tests/Domain/SettingsBuilderTest.php new file mode 100644 index 0000000..9218116 --- /dev/null +++ b/tests/Domain/SettingsBuilderTest.php @@ -0,0 +1,17 @@ +build(); + + $this->assertInstanceOf(Settings::class, $settings); + $obj = $settings->ToObject(); + $this->assertObjectNotHasProperty('encryption', $obj); + } +} \ No newline at end of file diff --git a/tests/EncryptorTest.php b/tests/EncryptorTest.php index 49df268..4f2c114 100644 --- a/tests/EncryptorTest.php +++ b/tests/EncryptorTest.php @@ -1,5 +1,7 @@ Date: Sat, 31 May 2025 13:58:31 +0700 Subject: [PATCH 2/2] [docs] update README --- README.md | 214 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 152 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index cdf394a..7352f3f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,58 @@ -# SMS Gateway for Androidβ„’ PHP API Client - -This is a PHP client library for interfacing with the [SMS Gateway for Android](https://sms-gate.app) API. - -## Requirements - -- PHP 7.4 or higher -- A PSR-18 compatible HTTP client implementation - -## Installation - -You can install the package via composer: +# πŸ“± SMS Gateway for Androidβ„’ PHP API Client + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=for-the-badge)](https://opensource.org/licenses/Apache-2.0) +[![Latest Stable Version](https://img.shields.io/packagist/v/capcom6/android-sms-gateway.svg?style=for-the-badge)](https://packagist.org/packages/capcom6/android-sms-gateway) +[![PHP Version Require](https://img.shields.io/packagist/php-v/capcom6/android-sms-gateway?style=for-the-badge)](https://packagist.org/packages/capcom6/android-sms-gateway) +[![Total Downloads](https://img.shields.io/packagist/dt/capcom6/android-sms-gateway.svg?style=for-the-badge)](https://packagist.org/packages/capcom6/android-sms-gateway) + +A modern PHP client for seamless integration with the [SMS Gateway for Android](https://sms-gate.app) API. Send SMS messages, manage devices, and configure webhooks through your PHP applications with this intuitive library. + +## πŸ”– Table of Contents + +- [πŸ“± SMS Gateway for Androidβ„’ PHP API Client](#-sms-gateway-for-android-php-api-client) + - [πŸ”– Table of Contents](#-table-of-contents) + - [✨ Features](#-features) + - [βš™οΈ Prerequisites](#️-prerequisites) + - [πŸ“¦ Installation](#-installation) + - [πŸš€ Quickstart](#-quickstart) + - [Sending an SMS](#sending-an-sms) + - [Managing Devices](#managing-devices) + - [πŸ“š Full API Reference](#-full-api-reference) + - [Client Initialization](#client-initialization) + - [Core Methods](#core-methods) + - [Builder Methods](#builder-methods) + - [πŸ”’ Security Notes](#-security-notes) + - [Best Practices](#best-practices) + - [Encryption Support](#encryption-support) + - [πŸ‘₯ Contributing](#-contributing) + - [Development Setup](#development-setup) + - [πŸ“„ License](#-license) + +## ✨ Features + +- **Builder Pattern**: Fluent interface for message and settings configuration +- **PSR Standards**: Compatible with any PSR-18 HTTP client +- **Comprehensive API**: Access to all SMS Gateway endpoints +- **Error Handling**: Structured exception management +- **Type Safety**: Strict typing throughout the codebase +- **Encryption Support**: End-to-end message encryption + +## βš™οΈ Prerequisites + +- PHP 7.4+ +- [Composer](https://getcomposer.org/) +- PSR-18 compatible HTTP client (e.g., [Guzzle](https://github.com/guzzle/guzzle)) +- SMS Gateway for Android account + +## πŸ“¦ Installation ```bash composer require capcom6/android-sms-gateway ``` -## Usage - -### Using the Builder Pattern - -The library provides builder classes for creating `Message` and `Settings` objects with numerous optional fields. - -#### Creating a Message +## πŸš€ Quickstart +### Sending an SMS ```php setTtl(3600) - ->setSimNumber(1) - ->setWithDeliveryReport(true) - ->setPriority(100) + ->setTtl(3600) // Message time-to-live in seconds + ->setSimNumber(1) // Use SIM slot 1 + ->setWithDeliveryReport(true) // Request delivery report + ->setPriority(100) // Higher priority message ->build(); +// Send message try { $messageState = $client->SendMessage($message); - echo "Message sent with ID: " . $messageState->ID() . PHP_EOL; -} catch (Exception $e) { - echo "Error sending message: " . $e->getMessage() . PHP_EOL; - die(1); + echo "βœ… Message sent! ID: " . $messageState->ID() . PHP_EOL; + + // Check status after delay + sleep(5); + $updatedState = $client->GetMessageState($messageState->ID()); + echo "πŸ“Š Message status: " . $updatedState->State() . PHP_EOL; +} catch (\Exception $e) { + echo "❌ Error: " . $e->getMessage() . PHP_EOL; + exit(1); } +``` + +### Managing Devices + +```php +// List registered devices +$devices = $client->ListDevices(); +echo "πŸ“± Registered devices: " . count($devices) . PHP_EOL; +// Remove a device try { - $messageState = $client->GetMessageState($messageState->ID()); - echo "Message state: " . $messageState->State() . PHP_EOL; -} catch (Exception $e) { - echo "Error getting message state: " . $e->getMessage() . PHP_EOL; - die(1); + $client->RemoveDevice('device-id-123'); + echo "πŸ—‘οΈ Device removed successfully" . PHP_EOL; +} catch (\Exception $e) { + echo "❌ Device removal failed: " . $e->getMessage() . PHP_EOL; } ``` -## Client +## πŸ“š Full API Reference -The `Client` is used for sending SMS messages in plain text, but can also be used for sending encrypted messages by providing an `Encryptor`. +### Client Initialization +```php +$client = new Client( + string $login, + string $password, + string $serverUrl = 'https://api.sms-gate.app/3rdparty/v1', + ?\Psr\Http\Client\ClientInterface $httpClient = null, + ?\AndroidSmsGateway\Encryptor $encryptor = null +); +``` -### Message Methods +### Core Methods + +| Category | Method | Description | +| ------------ | ---------------------------------------------------- | --------------------------------- | +| **Messages** | `SendMessage(Message $message)` | Send SMS message | +| | `GetMessageState(string $id)` | Get message status by ID | +| | `RequestInboxExport(MessagesExportRequest $request)` | Request inbox export via webhooks | +| **Devices** | `ListDevices()` | List registered devices | +| | `RemoveDevice(string $id)` | Remove device by ID | +| **System** | `HealthCheck()` | Check API health status | +| | `GetLogs(?string $from, ?string $to)` | Retrieve system logs | +| **Settings** | `GetSettings()` | Get account settings | +| | `PatchSettings(Settings $settings)` | Partially update account settings | +| | `ReplaceSettings(Settings $settings)` | Replace account settings | +| **Webhooks** | `ListWebhooks()` | List registered webhooks | +| | `RegisterWebhook(Webhook $webhook)` | Register new webhook | +| | `DeleteWebhook(string $id)` | Delete webhook by ID | + +### Builder Methods +```php +// Message Builder +$message = (new MessageBuilder(string $text, array $recipients)) + ->setTtl(int $seconds) + ->setSimNumber(int $simSlot) + ->setWithDeliveryReport(bool $enable) + ->setPriority(int $value) + ->build(); +``` -* `Send(Message $message)` (deprecated): Send a new SMS message. -* `SendMessage(Message $message)`: Send a new SMS message. -* `GetState(string $id)` (deprecated): Retrieve the state of a previously sent message by its ID. -* `GetMessageState(string $id)`: Retrieve the state of a previously sent message by its ID. +## πŸ”’ Security Notes -### Device Methods +### Best Practices -* `ListDevices()`: List all registered devices. -* `RemoveDevice(string $id)`: Remove a device by ID. +1. **Never store credentials in code** - Use environment variables: + ```php + $login = getenv('SMS_GATEWAY_LOGIN'); + $password = getenv('SMS_GATEWAY_PASSWORD'); + ``` +2. **Use HTTPS** - Ensure all API traffic is encrypted +3. **Validate inputs** - Sanitize phone numbers and message content +4. **Rotate credentials** - Regularly update your API credentials -### System Methods +### Encryption Support -* `HealthCheck()`: Check system health. -* `RequestInboxExport(object $request)`: Request inbox messages export. -* `GetLogs(?string $from = null, ?string $to = null)`: Get logs within a specified time range. +```php +use AndroidSmsGateway\Encryptor; -### Settings Methods +// Initialize client with encryption +$encryptor = new Encryptor('your-secret-passphrase'); +$client = new Client($login, $password, Client::DEFAULT_URL, null, $encryptor); +``` -* `GetSettings()`: Get user settings. -* `UpdateSettings(object $settings)`: Update user settings. -* `PatchSettings(object $settings)`: Partially update user settings. +## πŸ‘₯ Contributing -### Webhook Methods +We welcome contributions! Please follow these steps: -* `ListWebhooks()`: List all registered webhooks. -* `RegisterWebhook(object $webhook)`: Register a new webhook. -* `DeleteWebhook(string $id)`: Delete a webhook by ID. +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request -# Contributing +### Development Setup +```bash +git clone https://github.com/android-sms-gateway/client-php.git +cd client-php +composer install +``` -Contributions are welcome! Please submit a pull request or create an issue for anything you'd like to add or change. +## πŸ“„ License +This library is open-sourced software licensed under the [Apache-2.0 license](LICENSE). -# License +--- -This library is open-sourced software licensed under the [Apache-2.0 license](LICENSE). +**Note**: Android is a trademark of Google LLC. This project is not affiliated with or endorsed by Google.