config->isFirebaseConfigured()) { $this->logger->info('f7push: Firebase not configured, skip FCM send', ['app' => 'f7push']); return false; } $accessToken = $this->getAccessToken(); if ($accessToken === null) { return false; } $projectId = $this->config->getFirebaseProjectId(); $endpoint = 'https://fcm.googleapis.com/v1/projects/' . rawurlencode($projectId) . '/messages:send'; $notification = [ 'title' => $title, 'body' => $body, ]; $data = []; if ($url !== null && $url !== '') { $data['url'] = $url; } $data['click_action'] = 'FLUTTER_NOTIFICATION_CLICK'; $message = [ 'message' => [ 'token' => $fcmToken, 'notification' => $notification, 'data' => $data, 'android' => [ 'priority' => $priority === 'high' ? 'HIGH' : 'NORMAL', 'notification' => [ 'channel_id' => $priority === 'high' ? 'f7cloud_calls' : 'f7cloud_default', ], ], ], ]; $client = $this->clientService->newClient(); try { $response = $client->post($endpoint, [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken, 'Content-Type' => 'application/json', ], 'body' => json_encode($message, JSON_THROW_ON_ERROR), 'timeout' => 15, ]); return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300; } catch (\Throwable $e) { $this->logger->warning('f7push: FCM send failed', [ 'app' => 'f7push', 'exception' => $e, ]); return false; } } private function getAccessToken(): ?string { $cache = $this->getCache(); $cached = $cache->get(self::TOKEN_CACHE_KEY); if (is_string($cached) && $cached !== '') { return $cached; } $credentialsJson = $this->config->getFirebaseCredentials(); try { /** @var array $credentials */ $credentials = json_decode($credentialsJson, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { $this->logger->error('f7push: invalid firebase_credentials JSON', ['app' => 'f7push', 'exception' => $e]); return null; } $clientEmail = (string)($credentials['client_email'] ?? ''); $privateKey = (string)($credentials['private_key'] ?? ''); if ($clientEmail === '' || $privateKey === '') { $this->logger->error('f7push: firebase_credentials missing client_email or private_key', ['app' => 'f7push']); return null; } $now = time(); $header = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'], JSON_THROW_ON_ERROR)); $claims = $this->base64UrlEncode(json_encode([ 'iss' => $clientEmail, 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'aud' => 'https://oauth2.googleapis.com/token', 'iat' => $now, 'exp' => $now + 3600, ], JSON_THROW_ON_ERROR)); $input = $header . '.' . $claims; $signature = ''; $ok = openssl_sign($input, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (!$ok) { $this->logger->error('f7push: openssl_sign failed for Firebase JWT', ['app' => 'f7push']); return null; } $jwt = $input . '.' . $this->base64UrlEncode($signature); $client = $this->clientService->newClient(); try { $response = $client->post('https://oauth2.googleapis.com/token', [ 'body' => http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]), 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], 'timeout' => 15, ]); $data = json_decode($response->getBody(), true); $token = is_array($data) ? (string)($data['access_token'] ?? '') : ''; if ($token === '') { return null; } $cache->set(self::TOKEN_CACHE_KEY, $token, self::TOKEN_TTL); return $token; } catch (\Throwable $e) { $this->logger->error('f7push: Firebase OAuth token exchange failed', ['app' => 'f7push', 'exception' => $e]); return null; } } private function getCache(): ICache { return $this->cacheFactory->createDistributed('f7push'); } private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } }