Commit 41052e7e authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(native): sign-in with the phone's account, and real push notifications

For the rebuilt native app (root/el3ab-mobile):
- api/auth.php id_token: sign in with a Google or Apple ID token from the
  phone (GoTrue grant_type=id_token), instead of a web redirect Google
  refuses inside a WebView.
- Push through FCM HTTP v1 (includes/push.php): every notifications row is
  queued in push_outbox by a trigger, whoever wrote it (this app, the
  manager, SQL functions), and sent to the player's phones; challenges and
  chat messages queue a push of their own. Sent right after the request
  that made them (a fire-and-forget call to api/push.php dispatch) and by
  the scheduler every minute; dead tokens are dropped. api/push.php keeps
  the phones (register / unregister) and sends a test for the manager.
  The Firebase service account lives in platform_secrets (service only),
  set from the manager.
- The web bridge reports every refreshed session to the shell, asks for the
  native sign-in when the login screen opens inside the app, routes tapped
  pushes (tournament, chat, challenge), and answers the back button. The
  CSS that hid the web tab bar for a native bar is gone.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 1a4bd1e5
...@@ -24,6 +24,9 @@ switch ($action) { ...@@ -24,6 +24,9 @@ switch ($action) {
case 'refresh': case 'refresh':
handleRefresh($input); handleRefresh($input);
break; break;
case 'id_token':
handleIdToken($input);
break;
case 'logout': case 'logout':
handleLogout(); handleLogout();
break; break;
...@@ -194,6 +197,31 @@ function handleGuest(): void { ...@@ -194,6 +197,31 @@ function handleGuest(): void {
]); ]);
} }
/**
* Sign in with an ID token from the phone's own account (the native app:
* Google Sign-In on Android and iOS, Sign in with Apple), instead of a web
* redirect Google refuses inside a WebView. GoTrue checks the token against
* the provider (its audience must be one of GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID).
* The caller then runs profile.php ensure, as the web does after a redirect.
*/
function handleIdToken(array $input): void {
$provider = in_array($input['provider'] ?? '', ['google', 'apple'], true) ? $input['provider'] : '';
$idToken = (string)($input['id_token'] ?? '');
if ($provider === '' || $idToken === '' || strlen($idToken) > 8192) jsonError('provider and id_token are required');
$body = ['provider' => $provider, 'id_token' => $idToken];
if (!empty($input['nonce'])) $body['nonce'] = (string)$input['nonce'];
if (!empty($input['access_token'])) $body['access_token'] = (string)$input['access_token'];
$result = supabaseAuth('POST', 'token?grant_type=id_token', $body);
if (isset($result['error']) || empty($result['access_token'])) {
jsonError($result['error'] ?? 'Sign-in failed', (int)($result['code'] ?? 401));
}
jsonResponse([
'access_token' => $result['access_token'],
'refresh_token' => $result['refresh_token'] ?? null,
'user' => $result['user'] ?? null,
]);
}
function handleRefresh(array $input): void { function handleRefresh(array $input): void {
$refreshToken = $input['refresh_token'] ?? ''; $refreshToken = $input['refresh_token'] ?? '';
if (!$refreshToken) { if (!$refreshToken) {
......
...@@ -20,6 +20,7 @@ require_once __DIR__ . '/../includes/cors.php'; ...@@ -20,6 +20,7 @@ require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; } if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php'; require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/push.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/social.php'; require_once __DIR__ . '/../includes/social.php';
...@@ -222,6 +223,11 @@ if ($method === 'POST') { ...@@ -222,6 +223,11 @@ if ($method === 'POST') {
]); ]);
if (isset($rows['error']) || empty($rows[0])) jsonError('Send failed', 500); if (isset($rows['error']) || empty($rows[0])) jsonError('Send failed', 500);
$me = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'display_name,username', 'limit' => 1])[0] ?? [];
pushQueue($friendId, (string)($me['display_name'] ?? $me['username'] ?? 'رسالة جديدة'),
$type === 'invite' ? 'بعتلك تحدي' : mb_substr($content, 0, 120),
['type' => 'chat_message', 'route' => 'social', 'from_id' => $userId]);
$msg = shapeMessage($rows[0], $userId); $msg = shapeMessage($rows[0], $userId);
$msg['client_id'] = $clientId ?: null; $msg['client_id'] = $clientId ?: null;
jsonResponse(['message' => $msg, 'success' => true]); jsonResponse(['message' => $msg, 'success' => true]);
......
...@@ -73,6 +73,13 @@ if (tournamentThrottle('redeem_prune', 3600)) { ...@@ -73,6 +73,13 @@ if (tournamentThrottle('redeem_prune', 3600)) {
$report['redeem_attempts_pruned'] = redeemPruneAttempts(30); $report['redeem_attempts_pruned'] = redeemPruneAttempts(30);
} }
// Push: whatever the in-request kick missed (notifications written by the
// manager, SQL functions, a failed send), every tick.
require_once __DIR__ . '/../includes/push.php';
if ($onlyTournament === '') {
try { $report['push'] = pushDispatch(200); } catch (Throwable $e) { $report['errors'][] = 'push: ' . $e->getMessage(); }
}
// Casual tables everyone left 15+ minutes ago: closed, unpaid. Every 5 minutes. // Casual tables everyone left 15+ minutes ago: closed, unpaid. Every 5 minutes.
require_once __DIR__ . '/../includes/match-sweep.php'; require_once __DIR__ . '/../includes/match-sweep.php';
if ($onlyTournament === '' && tournamentThrottle('match_sweep', 300)) { if ($onlyTournament === '' && tournamentThrottle('match_sweep', 300)) {
......
...@@ -6,6 +6,7 @@ require_once __DIR__ . '/../includes/cors.php'; ...@@ -6,6 +6,7 @@ require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; } if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php'; require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/push.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/hand-off.php'; require_once __DIR__ . '/../includes/hand-off.php';
require_once __DIR__ . '/../includes/match-presence.php'; require_once __DIR__ . '/../includes/match-presence.php';
...@@ -1031,6 +1032,7 @@ function handleTableInvite(string $userId, array $input): void { ...@@ -1031,6 +1032,7 @@ function handleTableInvite(string $userId, array $input): void {
$me = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'display_name,username', 'limit' => 1]); $me = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'display_name,username', 'limit' => 1]);
$name = $me[0]['display_name'] ?? $me[0]['username'] ?? ''; $name = $me[0]['display_name'] ?? $me[0]['username'] ?? '';
$n = (int) ($row['player_count'] ?? 4); $n = (int) ($row['player_count'] ?? 4);
if (function_exists('pushKick')) pushKick();
$sdb->insert('notifications', [ $sdb->insert('notifications', [
'user_id' => $friend, 'user_id' => $friend,
'type' => 'domino_table', 'type' => 'domino_table',
......
...@@ -395,6 +395,12 @@ if ($method === 'POST') { ...@@ -395,6 +395,12 @@ if ($method === 'POST') {
jsonError('Could not send the challenge', 500); jsonError('Could not send the challenge', 500);
} }
// The phone rings even when the app is closed (includes/push.php).
$gameAr = ['chess' => 'شطرنج', 'ludo' => 'لودو', 'domino' => 'دومينو', 'backgammon' => 'طاولة'][$gameKey] ?? $gameKey;
$fromName = ($sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'display_name,username', 'limit' => 1])[0] ?? []);
pushQueue($targetId, ($fromName['display_name'] ?? $fromName['username'] ?? 'صاحبك') . ' بيتحداك', 'ماتش ' . $gameAr . '، ادخل العب',
['type' => 'match_invite', 'match_id' => $matchId, 'game_key' => $gameKey]);
$match = inviteMatch($sdb, $gameKey, $matchId) ?? []; $match = inviteMatch($sdb, $gameKey, $matchId) ?? [];
jsonResponse(array_merge([ jsonResponse(array_merge([
'match_id' => $matchId, 'match_id' => $matchId,
......
<?php
/**
* Push devices and sending (includes/push.php).
*
* The native app:
* POST {action:'register', token, platform: android|ios, app_version?} this phone gets pushes for me
* POST {action:'unregister', token} it stops (sign-out)
* Service (X-Service-Key: the manager, the server itself):
* POST {action:'dispatch'} send what is waiting in the outbox
* POST {action:'test', user_id | username} a test notification to that player's phones
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Service-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/push.php';
$input = getInput();
$action = (string)($input['action'] ?? '');
$serviceKey = (string)($_SERVER['HTTP_X_SERVICE_KEY'] ?? '');
$isService = SUPABASE_SERVICE_KEY !== '' && $serviceKey !== '' && hash_equals(SUPABASE_SERVICE_KEY, $serviceKey);
$sdb = supabaseService();
if ($action === 'dispatch') {
if (!$isService) jsonError('forbidden', 403);
ignore_user_abort(true); // the kick hangs up at once; the sending goes on
set_time_limit(60);
jsonResponse(pushDispatch(100));
}
if ($action === 'test') {
if (!$isService) jsonError('forbidden', 403);
$uid = (string)($input['user_id'] ?? '');
if ($uid === '' && !empty($input['username'])) {
$p = $sdb->get('profiles', ['username' => 'eq.' . trim((string)$input['username']), 'select' => 'id', 'limit' => 1]);
$uid = (string)($p[0]['id'] ?? '');
}
if (!preg_match('/^[0-9a-f-]{36}$/i', $uid)) jsonError('player not found', 404);
$devices = $sdb->get('push_tokens', ['user_id' => 'eq.' . $uid, 'select' => 'platform']);
if (!pushServiceAccount()) jsonResponse(['ok' => false, 'reason' => 'not_configured', 'devices' => count($devices)]);
$sdb->insert('notifications', ['user_id' => $uid, 'type' => 'test', 'title' => 'El3ab', 'title_ar' => 'العب',
'body' => 'Push notifications are working', 'body_ar' => 'الإشعارات شغالة', 'data' => ['route' => 'play']]);
$r = pushDispatch(20);
jsonResponse(['ok' => true, 'devices' => count($devices), 'dispatch' => $r]);
}
$token = requireAuth();
$userId = getUserId($token);
if ($action === 'register') {
$device = trim((string)($input['token'] ?? ''));
$platform = in_array($input['platform'] ?? '', ['android', 'ios'], true) ? $input['platform'] : '';
if ($device === '' || strlen($device) > 4096 || $platform === '') jsonError('token and platform are required');
// A phone that changes account moves with it: the token is the key.
$r = $sdb->upsert('push_tokens', ['user_id' => $userId, 'token' => $device, 'platform' => $platform,
'app_version' => substr((string)($input['app_version'] ?? ''), 0, 32) ?: null,
'updated_at' => gmdate('c'), 'last_error' => null], 'token');
if (isset($r['error'])) jsonError('could not save the device', 500);
jsonResponse(['ok' => true]);
}
if ($action === 'unregister') {
$device = trim((string)($input['token'] ?? ''));
if ($device !== '') $sdb->delete('push_tokens', ['token' => 'eq.' . $device, 'user_id' => 'eq.' . $userId]);
jsonResponse(['ok' => true]);
}
jsonError('Invalid action', 400);
<?php
/**
* Push notifications through Firebase Cloud Messaging (HTTP v1).
*
* Every notifications row is queued in push_outbox by a trigger
* (migrations/2026-10-27-push.sql), whoever wrote it. pushDispatch() claims
* pending rows, sends each to the player's registered devices (push_tokens,
* from the native app through api/push.php) and marks it sent; tokens Firebase
* calls dead are removed. It runs at the end of any request that made a
* notification (pushKick) and once a minute from the scheduler (api/cron.php).
*
* Settings live in platform_secrets, edited in the manager (Notifications ->
* Push settings): fcm_service_account, the JSON key of a Firebase service
* account (its project_id is the project). FCM_SERVICE_ACCOUNT_JSON in the
* environment works too. Without either, nothing is sent and the outbox keeps
* rows for a day, so a key set later still delivers recent ones.
*/
require_once __DIR__ . '/supabase.php';
const PUSH_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
const PUSH_ANDROID_CHANNEL = 'el3ab_game';
/** The service account (array), or null when push is not set up. */
function pushServiceAccount(): ?array {
static $sa = false;
if ($sa !== false) return $sa;
$raw = getenv('FCM_SERVICE_ACCOUNT_JSON') ?: '';
if ($raw === '') {
$rows = supabaseService()->get('platform_secrets', ['key' => 'eq.fcm_service_account', 'select' => 'value', 'limit' => 1]);
$raw = (is_array($rows) && !empty($rows[0]['value'])) ? (string)$rows[0]['value'] : '';
}
$j = $raw !== '' ? json_decode($raw, true) : null;
$sa = (is_array($j) && !empty($j['client_email']) && !empty($j['private_key']) && !empty($j['project_id'])) ? $j : null;
return $sa;
}
/** An OAuth access token for FCM, cached until shortly before it expires. */
function pushAccessToken(array $sa): ?string {
$cache = sys_get_temp_dir() . '/el3ab-fcm-' . md5($sa['client_email']) . '.json';
$c = is_file($cache) ? json_decode((string)@file_get_contents($cache), true) : null;
if (is_array($c) && ($c['exp'] ?? 0) > time() + 120) return $c['token'];
$b64 = fn(string $s) => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
$now = time();
$head = $b64(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claims = $b64(json_encode(['iss' => $sa['client_email'], 'scope' => PUSH_SCOPE,
'aud' => $sa['token_uri'] ?? 'https://oauth2.googleapis.com/token', 'iat' => $now, 'exp' => $now + 3600]));
$sig = '';
if (!openssl_sign($head . '.' . $claims, $sig, $sa['private_key'], 'sha256WithRSAEncryption')) return null;
$ch = curl_init($sa['token_uri'] ?? 'https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => http_build_query(['grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $head . '.' . $claims . '.' . $b64($sig)])]);
$r = json_decode((string)curl_exec($ch), true);
if (empty($r['access_token'])) { error_log('[push] token exchange failed: ' . json_encode($r)); return null; }
@file_put_contents($cache, json_encode(['token' => $r['access_token'], 'exp' => $now + (int)($r['expires_in'] ?? 3600)]));
return $r['access_token'];
}
/**
* Send one message to one device. Returns 'ok', 'dead' (the token is gone:
* remove it) or an error string.
*/
function pushSendOne(array $sa, string $accessToken, string $deviceToken, string $title, string $body, array $data): string {
$strData = [];
foreach ($data as $k => $v) {
$k = (string)$k;
if ($k === '' || preg_match('/^(google\.|gcm|from$|notification$|message_type$)/', $k)) continue;
$strData[$k] = is_scalar($v) || $v === null ? (string)$v : json_encode($v, JSON_UNESCAPED_UNICODE);
}
$msg = ['message' => [
'token' => $deviceToken,
'notification' => ['title' => $title, 'body' => $body],
'data' => (object)$strData,
'android' => ['priority' => 'HIGH', 'notification' => ['channel_id' => PUSH_ANDROID_CHANNEL, 'sound' => 'default']],
'apns' => ['headers' => ['apns-priority' => '10'], 'payload' => ['aps' => ['sound' => 'default']]],
]];
$ch = curl_init('https://fcm.googleapis.com/v1/projects/' . rawurlencode($sa['project_id']) . '/messages:send');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 8,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $accessToken],
CURLOPT_POSTFIELDS => json_encode($msg, JSON_UNESCAPED_UNICODE)]);
$res = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($code === 200) return 'ok';
$err = json_decode((string)$res, true)['error'] ?? [];
$status = (string)($err['status'] ?? '');
$detail = json_encode($err['details'] ?? []);
if ($code === 404 || $status === 'NOT_FOUND' || str_contains($detail, 'UNREGISTERED')
|| ($status === 'INVALID_ARGUMENT' && str_contains((string)($err['message'] ?? ''), 'registration token'))) return 'dead';
return 'http ' . $code . ' ' . $status;
}
/**
* Send what is waiting in the outbox. Returns a small report for cron.php.
*/
function pushDispatch(int $limit = 100): array {
$sa = pushServiceAccount();
if (!$sa) return ['configured' => false];
$sdb = supabaseService();
$claimed = supabaseRpc('push_outbox_claim', ['p_limit' => $limit, 'p_lease_seconds' => 60]);
if (!is_array($claimed) || isset($claimed['message']) || !$claimed) return ['configured' => true, 'sent' => 0];
$token = pushAccessToken($sa);
if (!$token) return ['configured' => true, 'error' => 'no_access_token'];
$ids = array_values(array_unique(array_filter(array_column($claimed, 'notification_id'))));
$users = array_values(array_unique(array_column($claimed, 'user_id')));
$notes = [];
if ($ids) foreach ($sdb->get('notifications', ['id' => 'in.(' . implode(',', $ids) . ')', 'select' => 'id,type,title,title_ar,body,body_ar,data']) as $n) $notes[$n['id']] = $n;
$devices = [];
foreach ($sdb->get('push_tokens', ['user_id' => 'in.(' . implode(',', $users) . ')', 'select' => 'id,user_id,token']) as $t) $devices[$t['user_id']][] = $t;
$sent = 0; $dead = 0; $failed = 0;
foreach ($claimed as $row) {
$n = !empty($row['notification_id']) ? ($notes[$row['notification_id']] ?? null)
: ['id' => '', 'type' => (string)(($row['data']['type'] ?? null) ?: 'direct'), 'title' => $row['title'], 'title_ar' => $row['title'],
'body' => $row['body'], 'body_ar' => $row['body'], 'data' => $row['data'] ?? []];
$list = $devices[$row['user_id']] ?? [];
if (!$n || !$list) {
$sdb->update('push_outbox', ['sent_at' => gmdate('c'), 'devices' => 0, 'last_error' => $n ? null : 'notification_gone'], ['id' => 'eq.' . $row['id']]);
continue;
}
// Arabic first, as the app reads.
$title = trim((string)($n['title_ar'] ?: $n['title'] ?: 'العب'));
$body = trim((string)($n['body_ar'] ?: $n['body'] ?: ''));
$data = is_array($n['data']) ? $n['data'] : (json_decode((string)$n['data'], true) ?: []);
$data += ['type' => (string)$n['type'], 'notification_id' => (string)$n['id']];
$ok = 0; $lastErr = null;
foreach ($list as $d) {
$r = pushSendOne($sa, $token, (string)$d['token'], $title, $body, $data);
if ($r === 'ok') { $ok++; continue; }
if ($r === 'dead') { $sdb->delete('push_tokens', ['id' => 'eq.' . $d['id']]); $dead++; continue; }
$lastErr = $r;
}
if ($ok > 0 || $lastErr === null) {
$sdb->update('push_outbox', ['sent_at' => gmdate('c'), 'devices' => $ok, 'last_error' => $lastErr], ['id' => 'eq.' . $row['id']]);
$sent++;
} else {
$sdb->update('push_outbox', ['last_error' => $lastErr, 'claimed_at' => null], ['id' => 'eq.' . $row['id']]);
$failed++;
}
}
return ['configured' => true, 'sent' => $sent, 'failed' => $failed, 'dead_tokens' => $dead];
}
/**
* A push with no notification row (a challenge, a chat message), sent with
* the outbox like the rest. Players with no registered phone cost one insert.
*/
function pushQueue(string $userId, string $title, string $body, array $data = []): void {
if (!preg_match('/^[0-9a-f-]{36}$/i', $userId)) return;
$sdb = supabaseService();
$has = $sdb->get('push_tokens', ['user_id' => 'eq.' . $userId, 'select' => 'id', 'limit' => 1]);
if (!is_array($has) || !$has || isset($has['error'])) return;
$sdb->insert('push_outbox', ['user_id' => $userId, 'title' => $title, 'body' => $body, 'data' => $data]);
pushKick();
}
/**
* Send this request's notifications without holding its response: once per
* request, a fire-and-forget call to api/push.php dispatch on this same server
* (mod_php cannot finish a response early). The scheduler sends whatever this
* misses within a minute.
*/
function pushKick(): void {
static $armed = false;
if ($armed) return;
$armed = true;
register_shutdown_function(function () {
if (!pushServiceAccount()) return;
$self = rtrim(getenv('PUSH_SELF_URL') ?: 'http://127.0.0.1', '/');
$ch = curl_init($self . '/api/push.php');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_NOSIGNAL => true,
CURLOPT_TIMEOUT_MS => 400, CURLOPT_CONNECTTIMEOUT_MS => 200,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Service-Key: ' . SUPABASE_SERVICE_KEY],
CURLOPT_POSTFIELDS => json_encode(['action' => 'dispatch'])]);
@curl_exec($ch);
});
}
<?php <?php
require_once __DIR__ . '/push.php';
/** /**
* Shared pieces of the social system: presence, notifications, blocks. * Shared pieces of the social system: presence, notifications, blocks.
* *
...@@ -84,6 +85,7 @@ function profileOf($sdb, string $id, string $cols = PLAYER_COLS): ?array { ...@@ -84,6 +85,7 @@ function profileOf($sdb, string $id, string $cols = PLAYER_COLS): ?array {
* name so the app can word the notice in the reader's language. * name so the app can word the notice in the reader's language.
*/ */
function socialNotify($sdb, string $uid, string $type, string $titleAr, string $titleEn, array $data = [], string $bodyAr = '', string $bodyEn = ''): void { function socialNotify($sdb, string $uid, string $type, string $titleAr, string $titleEn, array $data = [], string $bodyAr = '', string $bodyEn = ''): void {
if (function_exists('pushKick')) pushKick();
$sdb->insert('notifications', [ $sdb->insert('notifications', [
'user_id' => $uid, 'user_id' => $uid,
'type' => $type, 'type' => $type,
......
<?php <?php
require_once __DIR__ . '/push.php';
/** /**
* Tournament notifications (package P5; spec 4.10 step 9, contract D11). * Tournament notifications (package P5; spec 4.10 step 9, contract D11).
...@@ -50,6 +51,7 @@ if (!defined('TOURNAMENT_PLAYER_APP_URL')) { ...@@ -50,6 +51,7 @@ if (!defined('TOURNAMENT_PLAYER_APP_URL')) {
* already notified under this kind, and players with no profile, are skipped. * already notified under this kind, and players with no profile, are skipped.
*/ */
function tournamentNotify($sdb, array $t, array $playerIds, string $type, string $kind, array $payload): int { function tournamentNotify($sdb, array $t, array $playerIds, string $type, string $kind, array $payload): int {
if (function_exists('pushKick')) pushKick();
$tid = (string)($t['id'] ?? ''); $tid = (string)($t['id'] ?? '');
if ($tid === '' || $kind === '' || $type === '') return 0; if ($tid === '' || $kind === '' || $type === '') return 0;
$ids = array_values(array_unique(array_filter(array_map('strval', $playerIds), 'tournamentNotifyIsUuid'))); $ids = array_values(array_unique(array_filter(array_map('strval', $playerIds), 'tournamentNotifyIsUuid')));
......
<?php <?php
require_once __DIR__ . '/push.php';
/** /**
* Tournament registration for players: who may enter, entering (with the * Tournament registration for players: who may enter, entering (with the
* form, the represented club, the waitlist and a code), check-in, the club a * form, the represented club, the waitlist and a code), check-in, the club a
...@@ -495,6 +496,7 @@ function tournamentNotifyPromoted($sdb, array $t, array $promoted): int { ...@@ -495,6 +496,7 @@ function tournamentNotifyPromoted($sdb, array $t, array $promoted): int {
$mark = $sdb->insert('tournament_notices_sent', ['tournament_id' => $t['id'], 'player_id' => $pid, 'kind' => 'waitlist_promoted']); $mark = $sdb->insert('tournament_notices_sent', ['tournament_id' => $t['id'], 'player_id' => $pid, 'kind' => 'waitlist_promoted']);
if (isset($mark['error'])) continue; // already told, or not a real profile if (isset($mark['error'])) continue; // already told, or not a real profile
$fee = $fees[$pid] ?? 0; $fee = $fees[$pid] ?? 0;
if (function_exists('pushKick')) pushKick();
$sdb->insert('notifications', [ $sdb->insert('notifications', [
'user_id' => $pid, 'type' => 'tournament_waitlist', 'user_id' => $pid, 'type' => 'tournament_waitlist',
'title' => 'You are off the waitlist', 'title_ar' => 'طلعت من قائمة الانتظار', 'title' => 'You are off the waitlist', 'title_ar' => 'طلعت من قائمة الانتظار',
......
-- Real push notifications (FCM) for the native app.
--
-- push_tokens one row per device token (a player can have several
-- phones); the app registers it through api/push.php.
-- push_outbox every new notification row is queued here by a trigger,
-- whoever wrote it (the player app, the manager, a SQL
-- function); includes/push.php sends it to the player's
-- devices and marks it sent.
-- platform_secrets service-only settings the manager edits (the Firebase
-- project id and service account); never readable by players.
BEGIN;
SET LOCAL lock_timeout = '5s';
-- Device tokens: unique per token (the old upsert key was user_id+platform,
-- which kept one phone per platform).
DELETE FROM public.push_tokens a USING public.push_tokens b
WHERE a.token = b.token AND a.updated_at < b.updated_at;
ALTER TABLE public.push_tokens
ADD COLUMN IF NOT EXISTS app_version text,
ADD COLUMN IF NOT EXISTS last_error text;
CREATE UNIQUE INDEX IF NOT EXISTS push_tokens_token_key ON public.push_tokens (token);
CREATE INDEX IF NOT EXISTS push_tokens_user_idx ON public.push_tokens (user_id);
ALTER TABLE public.push_tokens ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.push_tokens FROM anon, authenticated;
CREATE TABLE IF NOT EXISTS public.push_outbox (
id bigserial PRIMARY KEY,
notification_id uuid NOT NULL UNIQUE,
user_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz,
devices integer NOT NULL DEFAULT 0,
attempts integer NOT NULL DEFAULT 0,
last_error text
);
CREATE INDEX IF NOT EXISTS push_outbox_pending_idx ON public.push_outbox (created_at) WHERE sent_at IS NULL;
ALTER TABLE public.push_outbox ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.push_outbox FROM anon, authenticated;
CREATE OR REPLACE FUNCTION public.push_outbox_enqueue() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $$
BEGIN
IF NEW.user_id IS NOT NULL THEN
INSERT INTO push_outbox (notification_id, user_id) VALUES (NEW.id, NEW.user_id)
ON CONFLICT (notification_id) DO NOTHING;
END IF;
RETURN NEW;
END $$;
REVOKE EXECUTE ON FUNCTION public.push_outbox_enqueue() FROM PUBLIC, anon, authenticated;
DROP TRIGGER IF EXISTS notifications_push_outbox ON public.notifications;
CREATE TRIGGER notifications_push_outbox AFTER INSERT ON public.notifications
FOR EACH ROW EXECUTE FUNCTION public.push_outbox_enqueue();
ALTER TABLE public.push_outbox ADD COLUMN IF NOT EXISTS claimed_at timestamptz;
-- A push with no notification row behind it (a challenge, a chat message: the
-- inbox would drown in them) carries its own words.
ALTER TABLE public.push_outbox ALTER COLUMN notification_id DROP NOT NULL;
ALTER TABLE public.push_outbox
ADD COLUMN IF NOT EXISTS title text,
ADD COLUMN IF NOT EXISTS body text,
ADD COLUMN IF NOT EXISTS data jsonb;
-- Hand out pending rows to one sender at a time (the request that made the
-- notification and the scheduler may both try): a claim holds a row for
-- p_lease_seconds, and a row is tried at most 5 times over a day.
CREATE OR REPLACE FUNCTION public.push_outbox_claim(p_limit integer DEFAULT 50, p_lease_seconds integer DEFAULT 60)
RETURNS SETOF public.push_outbox
LANGUAGE sql SECURITY DEFINER SET search_path TO 'public' AS $$
UPDATE push_outbox o
SET attempts = o.attempts + 1, claimed_at = now()
WHERE o.id IN (
SELECT id FROM push_outbox
WHERE sent_at IS NULL AND attempts < 5 AND created_at > now() - interval '1 day'
AND (claimed_at IS NULL OR claimed_at < now() - make_interval(secs => p_lease_seconds))
ORDER BY created_at
LIMIT greatest(1, least(p_limit, 500))
FOR UPDATE SKIP LOCKED)
RETURNING o.*;
$$;
REVOKE EXECUTE ON FUNCTION public.push_outbox_claim(integer, integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.push_outbox_claim(integer, integer) TO service_role;
CREATE TABLE IF NOT EXISTS public.platform_secrets (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by text
);
ALTER TABLE public.platform_secrets ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.platform_secrets FROM anon, authenticated;
COMMIT;
NOTIFY pgrst, 'reload schema';
...@@ -892,5 +892,4 @@ a:not(.btn):not([class*="tab"]) { display: inline-flex; align-items: center; min ...@@ -892,5 +892,4 @@ a:not(.btn):not([class*="tab"]) { display: inline-flex; align-items: center; min
} }
/* Native app mode */ /* Native app mode */
.native-app .bottom-nav { display: none !important; }
.native-app .tab-bar { padding-bottom: calc(var(--safe-bottom) + 8px); } .native-app .tab-bar { padding-bottom: calc(var(--safe-bottom) + 8px); }
...@@ -24,6 +24,13 @@ export function init() { ...@@ -24,6 +24,13 @@ export function init() {
onNavTap: handleNavTap, onNavTap: handleNavTap,
// The shell's QR scanner answers requestScan() here: { text } or { error }. // The shell's QR scanner answers requestScan() here: { text } or { error }.
onScanResult: handleScanResult, onScanResult: handleScanResult,
// Android's back button: one scene back while there is one; false lets
// the shell close the app instead.
onBack: () => {
const before = scene.currentSceneId();
scene.pop();
return scene.currentSceneId() !== before;
},
}; };
// Override haptic to use native // Override haptic to use native
...@@ -35,16 +42,21 @@ export function init() { ...@@ -35,16 +42,21 @@ export function init() {
}; };
} }
// Listen for auth events to notify Flutter // The native shell signs players in (Google with the phone's account,
bus.on('auth:success', () => { // Apple, email) and hands the session over; it keeps the newest tokens so
// the next launch opens signed in. The web refreshes the tokens, so every
// new pair goes back to the shell.
const reportSession = () => {
const token = store.get('auth.token'); const token = store.get('auth.token');
const userId = store.get('auth.userId'); if (!token) return;
send('auth_ready', { token, userId }); send('auth_tokens', {
}); token, refreshToken: store.get('auth.refreshToken') || null,
userId: store.get('auth.userId') || null, isGuest: !!store.get('auth.isGuest'),
bus.on('auth:logout', () => { });
send('auth_logout', {}); };
}); bus.on('auth:success', reportSession);
bus.on('store:auth.token', reportSession);
bus.on('auth:logout', () => send('auth_logout', {}));
// Listen for world/nav changes to sync Flutter bottom bar // Listen for world/nav changes to sync Flutter bottom bar
bus.on('scene:world_changed', (world) => { bus.on('scene:world_changed', (world) => {
...@@ -104,21 +116,25 @@ function handleDeepLink(data) { ...@@ -104,21 +116,25 @@ function handleDeepLink(data) {
} }
} }
function handlePushTap(data) { function handlePushTap(data = {}) {
const { type, match_id, route } = data; const { type = '', match_id, route } = data;
// A tournament notice carries its tournament: open it.
if (data.tournament_id) {
scene.switchWorld('tournaments');
scene.push('tournament-detail', { tournamentId: data.tournament_id });
return;
}
switch (type) { switch (type) {
case 'match_invite': case 'match_invite':
case 'your_turn': // The challenge waits on the table (play/invite-watch.js shows it).
case 'match_result': scene.switchWorld('play', true);
if (match_id) bus.emit('native:open_match', match_id);
break;
case 'tournament_start':
scene.switchWorld('tournaments');
break;
case 'friend_online':
scene.switchWorld('social');
break; break;
case 'chat_message': case 'chat_message':
case 'friend_request':
case 'friend_accept':
case 'friend_online':
case 'group_added':
case 'cheer':
scene.switchWorld('social'); scene.switchWorld('social');
break; break;
default: default:
......
...@@ -69,6 +69,11 @@ export function showError(root, slotId, text, shake = false) { ...@@ -69,6 +69,11 @@ export function showError(root, slotId, text, shake = false) {
} }
export function mountLogin(el, params = {}) { export function mountLogin(el, params = {}) {
// Inside the native app the shell signs players in (the phone's Google
// account, Apple, email): it shows its own screen over this one.
if (window.IS_NATIVE_APP && window.flutter_bridge?.postMessage) {
try { window.flutter_bridge.postMessage(JSON.stringify({ cmd: 'auth_required' })); } catch (e) {}
}
// An invite is held (a /r/ link opened while signed out): the invitation // An invite is held (a /r/ link opened while signed out): the invitation
// sits on top, and making an account to use it is the big green key. // sits on top, and making an account to use it is the big green key.
const held = deeplink.pendingCode(); const held = deeplink.pendingCode();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment