chore: استيراد أولي من سيرو (ecfe7568) — بلا أي تعديل
نسخة كاملة من مستودع سيرو عند ecfe7568 لتكون أساس تطبيق «انطلق». نُسخ المتعقَّب في git فقط (12,509 ملفاً / 302 م.ب) بـ git archive، لا `cp -r` — فاستُثنيت تلقائياً مخلفات البناء (build · node_modules · .dart_tool · .gradle · Pods ≈ 10.7 غ.ب) وكل ما يستثنيه .gitignore. هذا الكوميت **بلا أي تعديل عمداً** حتى يكون كل ما يليه فرقاً مقروءاً مقابل سيرو الأصلي. سيرو نفسه لم يُمسّ. ⚠️ لا يبني بعد: `.env` و`lib/env/env.g.dart` غير متعقَّبين في سيرو (وهذا صحيح — أسرار لكل مستأجر). كل تطبيق فلاتر هنا يحتاج .env خاصاً بانطلق ثم توليد env.g.dart عبر build_runner. لا تُنسخ أسرار سيرو. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// core/Services/FcmService.php
|
||||
// إرسال FCM مع كاش توكن في Redis (بدل ملف)
|
||||
// ============================================================
|
||||
|
||||
class FcmService
|
||||
{
|
||||
private ?Redis $redis;
|
||||
private string $serviceAccountFile;
|
||||
|
||||
public function __construct(?Redis $redis = null)
|
||||
{
|
||||
$this->redis = $redis;
|
||||
$envPath = getenv('FIREBASE_SERVICE_ACCOUNT_PATH') ?: '';
|
||||
if (!empty($envPath) && file_exists($envPath)) {
|
||||
$this->serviceAccountFile = $envPath;
|
||||
} elseif (file_exists('/keys/firebase_service_account.json')) {
|
||||
$this->serviceAccountFile = '/keys/firebase_service_account.json';
|
||||
} elseif (file_exists('/keys/service-account.json')) {
|
||||
$this->serviceAccountFile = '/keys/service-account.json';
|
||||
} elseif (file_exists(__DIR__ . '/../../keys/firebase_service_account.json')) {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json';
|
||||
} elseif (file_exists(__DIR__ . '/../../ride/firebase/service-account.json')) {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../ride/firebase/service-account.json';
|
||||
} else {
|
||||
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json'; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
// ── إرسال إشعار ────────────────────────────────────────
|
||||
public function send(
|
||||
string $token,
|
||||
string $title,
|
||||
string $body,
|
||||
array $data = [],
|
||||
string $category = 'Order',
|
||||
string $tone = 'ding'
|
||||
): array {
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return ['status' => 'error', 'message' => 'No access token'];
|
||||
}
|
||||
|
||||
if (!file_exists($this->serviceAccountFile)) {
|
||||
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||
}
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$projectId = $creds['project_id'];
|
||||
$fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
|
||||
|
||||
$finalData = array_merge($data, [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'tone' => $tone,
|
||||
'category' => $category,
|
||||
'type' => $category,
|
||||
]);
|
||||
|
||||
// FCM يشترط أن تكون كل القيم strings
|
||||
$processedData = array_map(
|
||||
fn($v) => is_array($v) || is_object($v)
|
||||
? json_encode($v, JSON_UNESCAPED_UNICODE)
|
||||
: (string)$v,
|
||||
$finalData
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'token' => $token,
|
||||
'data' => $processedData,
|
||||
'android' => [
|
||||
'priority' => 'HIGH',
|
||||
'notification' => [
|
||||
'sound' => $tone === 'ding' ? 'default' : $tone,
|
||||
'channel_id' => 'high_importance_channel'
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($title) && !empty($body)) {
|
||||
$payload['message']['notification'] = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
];
|
||||
$iosSound = $tone === 'ding' || $tone === 'default' ? 'default' : (str_ends_with($tone, '.caf') ? $tone : $tone . '.caf');
|
||||
$payload['message']['apns'] = [
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'sound' => $iosSound
|
||||
]
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$payload['message']['apns'] = [
|
||||
'headers' => [
|
||||
'apns-priority' => '5',
|
||||
'apns-push-type' => 'background'
|
||||
],
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'content-available' => 1
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer $accessToken",
|
||||
'Content-Type: application/json; charset=UTF-8',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FRESH_CONNECT => false, // إعادة استخدام الاتصال
|
||||
CURLOPT_FORBID_REUSE => false,
|
||||
CURLOPT_TCP_KEEPALIVE => 1,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
|
||||
error_log("[FCM_DEBUG] Token: " . substr($token, 0, 10) . "... Payload: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . " | Result: $httpCode - $result");
|
||||
|
||||
if ($curlErr) {
|
||||
return ['status' => 'error', 'message' => 'CURL error'];
|
||||
}
|
||||
|
||||
return $httpCode === 200
|
||||
? ['status' => 'success']
|
||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
// ── إرسال إشعار لـ FCM Topic (قناة المواصلاتي وغيرها) ──
|
||||
public function sendToTopic(
|
||||
string $topic,
|
||||
string $title,
|
||||
string $body,
|
||||
array $data = []
|
||||
): array {
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) return ['status' => 'error', 'message' => 'No access token'];
|
||||
|
||||
if (!file_exists($this->serviceAccountFile)) {
|
||||
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||
}
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$projectId = $creds['project_id'];
|
||||
$fcmUrl = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
|
||||
|
||||
$processedData = array_map(
|
||||
fn($v) => is_array($v) || is_object($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string)$v,
|
||||
array_merge($data, ['title' => $title, 'body' => $body])
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'topic' => $topic,
|
||||
'notification' => ['title' => $title, 'body' => $body],
|
||||
'data' => $processedData,
|
||||
'android' => ['priority' => 'HIGH'],
|
||||
],
|
||||
];
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: Bearer $accessToken", 'Content-Type: application/json; charset=UTF-8'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
]);
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode === 200
|
||||
? ['status' => 'success']
|
||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||
}
|
||||
|
||||
// ── Access Token مع Redis Cache ─────────────────────────
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
// 1. من Redis
|
||||
if ($this->redis) {
|
||||
$cached = $this->redis->get('google_fcm_access_token');
|
||||
if ($cached) return $cached;
|
||||
}
|
||||
|
||||
// 2. طلب جديد
|
||||
$token = $this->fetchGoogleToken();
|
||||
|
||||
if ($token && $this->redis) {
|
||||
$this->redis->setex('google_fcm_access_token', 3500, $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function fetchGoogleToken(): ?string
|
||||
{
|
||||
if (!file_exists($this->serviceAccountFile)) return null;
|
||||
|
||||
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||
$clientEmail = $creds['client_email'];
|
||||
$privateKey = $creds['private_key'];
|
||||
$now = time();
|
||||
|
||||
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||
$claim = rtrim(strtr(base64_encode(json_encode([
|
||||
'iss' => $clientEmail,
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
])), '+/', '-_'), '=');
|
||||
|
||||
$signature = '';
|
||||
openssl_sign("$header.$claim", $signature, $privateKey, 'SHA256');
|
||||
$jwt = "$header.$claim." . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
||||
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return json_decode($res, true)['access_token'] ?? null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user