FCM push Notifications
This commit is contained in:
parent
1807dca57d
commit
6ccf820438
|
|
@ -45,6 +45,9 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="tasq_high_importance_channel" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
|
|||
|
|
@ -22,17 +22,12 @@ import 'services/notification_bridge.dart';
|
|||
|
||||
/// Handle messages received while the app is terminated or in background.
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
// initialize plugin in background isolate
|
||||
await NotificationService.initialize();
|
||||
final notification = message.notification;
|
||||
if (notification != null) {
|
||||
NotificationService.show(
|
||||
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title: notification.title ?? 'Notification',
|
||||
body: notification.body ?? '',
|
||||
payload: message.data['payload'],
|
||||
);
|
||||
}
|
||||
// Do minimal work in background isolate. Avoid initializing Flutter
|
||||
// plugins here (e.g. flutter_local_notifications) as that can hang
|
||||
// the background isolate and interfere with subsequent launches.
|
||||
// If you need to persist data from a background message, perform a
|
||||
// lightweight HTTP call or write to a background-capable DB.
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
|
|
@ -58,22 +53,9 @@ Future<void> main() async {
|
|||
|
||||
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
|
||||
|
||||
// ensure token saved right away if already signed in
|
||||
// ensure token saved shortly after startup if already signed in.
|
||||
// Run this after runApp so startup is not blocked by network/token ops.
|
||||
final supaClient = Supabase.instance.client;
|
||||
String? initialToken;
|
||||
if (!kIsWeb) {
|
||||
try {
|
||||
initialToken = await FirebaseMessaging.instance.getToken();
|
||||
} catch (e) {
|
||||
debugPrint('FCM getToken failed: $e');
|
||||
initialToken = null;
|
||||
}
|
||||
if (initialToken != null && supaClient.auth.currentUser != null) {
|
||||
debugPrint('initial FCM token for signed-in user: $initialToken');
|
||||
final ctrl = NotificationsController(supaClient);
|
||||
await ctrl.registerFcmToken(initialToken);
|
||||
}
|
||||
}
|
||||
|
||||
// listen for auth changes to register/unregister token accordingly
|
||||
supaClient.auth.onAuthStateChange.listen((data) async {
|
||||
|
|
@ -152,6 +134,24 @@ Future<void> main() async {
|
|||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Post-startup: register current FCM token without blocking UI.
|
||||
if (!kIsWeb) {
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken().timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
if (token != null && supaClient.auth.currentUser != null) {
|
||||
debugPrint('post-startup registering FCM token: $token');
|
||||
final ctrl = NotificationsController(supaClient);
|
||||
await ctrl.registerFcmToken(token);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('post-startup FCM token registration failed: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationSoundObserver extends ProviderObserver {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'notifications_provider.dart';
|
||||
import '../utils/device_id.dart';
|
||||
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
|
|
@ -64,6 +67,23 @@ class AuthController {
|
|||
}
|
||||
|
||||
Future<void> signOut() {
|
||||
// Attempt to unregister this device's FCM token before signing out.
|
||||
return _doSignOut();
|
||||
}
|
||||
|
||||
Future<void> _doSignOut() async {
|
||||
try {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId != null) {
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token != null) {
|
||||
final ctrl = NotificationsController(_client);
|
||||
await ctrl.unregisterFcmToken(token);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
return _client.auth.signOut();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../utils/device_id.dart';
|
||||
|
||||
import '../models/notification_item.dart';
|
||||
import 'profile_provider.dart';
|
||||
|
|
@ -165,10 +166,16 @@ class NotificationsController {
|
|||
Future<void> registerFcmToken(String token) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
final res = await _client.from('fcm_tokens').insert({
|
||||
final deviceId = await DeviceId.getId();
|
||||
// upsert using a unique constraint on (user_id, device_id) so a single
|
||||
// device keeps its token updated without overwriting other devices.
|
||||
final payload = {
|
||||
'user_id': userId,
|
||||
'device_id': deviceId,
|
||||
'token': token,
|
||||
});
|
||||
'created_at': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
final res = await _client.from('fcm_tokens').upsert(payload);
|
||||
if (res == null) {
|
||||
debugPrint(
|
||||
'registerFcmToken: null response for user=$userId token=$token',
|
||||
|
|
@ -189,11 +196,14 @@ class NotificationsController {
|
|||
Future<void> unregisterFcmToken(String token) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
final deviceId = await DeviceId.getId();
|
||||
// Prefer to delete by device_id to avoid removing other devices' tokens.
|
||||
final res = await _client
|
||||
.from('fcm_tokens')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('token', token);
|
||||
.eq('device_id', deviceId)
|
||||
.or('token.eq.$token');
|
||||
if (res == null) {
|
||||
debugPrint(
|
||||
'unregisterFcmToken: null response for user=$userId token=$token',
|
||||
|
|
|
|||
23
lib/utils/device_id.dart
Normal file
23
lib/utils/device_id.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class DeviceId {
|
||||
static const _key = 'tasq_device_id';
|
||||
|
||||
/// Returns a stable UUID for this installation. Generated once and persisted
|
||||
/// in `SharedPreferences`.
|
||||
static Future<String> getId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var id = prefs.getString(_key);
|
||||
if (id != null && id.isNotEmpty) return id;
|
||||
id = const Uuid().v4();
|
||||
await prefs.setString(_key, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// For testing or explicit reset.
|
||||
static Future<void> reset() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key);
|
||||
}
|
||||
}
|
||||
|
|
@ -1246,7 +1246,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
|
|
@ -1483,7 +1483,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ dependencies:
|
|||
flutter_local_notifications: ^20.1.0
|
||||
firebase_core: ^4.4.0
|
||||
firebase_messaging: ^16.1.1
|
||||
shared_preferences: ^2.2.0
|
||||
uuid: ^4.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
||||
import { JWT } from 'npm:google-auth-library@9';
|
||||
|
||||
// we no longer use google-auth-library or a service account; instead
|
||||
// send FCM via a legacy server key provided as an environment variable
|
||||
|
|
@ -52,24 +53,87 @@ Deno.serve(async (req) => {
|
|||
}
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: {title: notificationTitle, body: notificationBody}, error: 'step5', stack: null }); } catch (_) {}
|
||||
|
||||
// use legacy server key for FCM send
|
||||
const serverKey = Deno.env.get('FCM_SERVER_KEY');
|
||||
if (serverKey && tokens.length) {
|
||||
const sendPromises = tokens.map((tok) => {
|
||||
return fetch('https://fcm.googleapis.com/fcm/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `key=${serverKey}`,
|
||||
},
|
||||
body: JSON.stringify({ to: tok, notification: { title: notificationTitle, body: notificationBody }, data: body.data || {} }),
|
||||
});
|
||||
});
|
||||
// Use Firebase HTTP v1 API with service account JSON (recommended).
|
||||
const serviceAccountStr = Deno.env.get('FIREBASE_SERVICE_ACCOUNT_JSON');
|
||||
if (!serviceAccountStr) {
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: null, error: 'missing_service_account', stack: null }); } catch (_) {}
|
||||
} else {
|
||||
let serviceAccount: any = null;
|
||||
try {
|
||||
const results = await Promise.all(sendPromises);
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: results, error: 'step8', stack: null }); } catch (_) {}
|
||||
serviceAccount = JSON.parse(serviceAccountStr);
|
||||
} catch (e) {
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'step8error', stack: null }); } catch (_) {}
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'service_account_parse_error', stack: null }); } catch (_) {}
|
||||
}
|
||||
|
||||
if (serviceAccount && tokens.length) {
|
||||
// helper to get short-lived OAuth2 access token
|
||||
const getAccessToken = async () => {
|
||||
const jwtClient = new JWT({
|
||||
email: serviceAccount.client_email,
|
||||
key: serviceAccount.private_key,
|
||||
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
|
||||
});
|
||||
const tokens = await jwtClient.authorize();
|
||||
return tokens.access_token as string | undefined;
|
||||
};
|
||||
|
||||
const accessToken = await getAccessToken().catch(async (e) => {
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'access_token_error', stack: null }); } catch (_) {}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (!accessToken) {
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: null, error: 'no_access_token', stack: null }); } catch (_) {}
|
||||
} else {
|
||||
const projectId = serviceAccount.project_id;
|
||||
const fcmEndpoint = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;
|
||||
|
||||
// send concurrently per token (HTTP v1 doesn't support registration_ids)
|
||||
const sendPromises = tokens.map(async (token) => {
|
||||
const payload = {
|
||||
message: {
|
||||
token: token,
|
||||
notification: { title: notificationTitle, body: notificationBody },
|
||||
data: body.data || {},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(fcmEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
|
||||
// Log result for debugging
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: json ?? { status: res.status }, error: 'send_fcm_result', stack: null }); } catch (_) {}
|
||||
|
||||
// Token invalidation handling
|
||||
if (!res.ok && json && json.error) {
|
||||
// Example detail: json.error.details?.[0]?.errorCode === 'UNREGISTERED'
|
||||
const detailCode = json.error.details && Array.isArray(json.error.details) && json.error.details[0] ? json.error.details[0].errorCode : undefined;
|
||||
const status = json.error.status || json.error.code || res.status;
|
||||
if (detailCode === 'UNREGISTERED' || status === 'NOT_FOUND' || status === 404) {
|
||||
try { await supabase.from('fcm_tokens').delete().eq('token', token); } catch (_) {}
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: { token, error: detailCode || status }, error: 'removed_dead_token', stack: null }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
return { token, status: res.status, response: json };
|
||||
} catch (e) {
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'send_exception', stack: null }); } catch (_) {}
|
||||
return { token, status: 0, response: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for send results but don't fail the function if some fail
|
||||
const results = await Promise.all(sendPromises);
|
||||
try { await supabase.from('send_fcm_errors').insert({ payload: results, error: 'send_batch_complete', stack: null }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
-- add device_id column to fcm_tokens and a unique constraint on (user_id, device_id)
|
||||
|
||||
alter table if exists public.fcm_tokens
|
||||
add column if not exists device_id text;
|
||||
|
||||
-- create a unique index so upsert can update the row for the same device
|
||||
create unique index if not exists fcm_tokens_user_device_idx
|
||||
on public.fcm_tokens(user_id, device_id);
|
||||
|
||||
-- ensure device_id is protected by RLS policies: allow users to insert/update/delete their device rows
|
||||
-- (these policies assume RLS is already enabled on the table)
|
||||
create policy if not exists "Allow users insert their device tokens" on public.fcm_tokens
|
||||
for insert with check (auth.uid() = user_id);
|
||||
|
||||
create policy if not exists "Allow users delete their device tokens" on public.fcm_tokens
|
||||
for delete using (auth.uid() = user_id);
|
||||
|
||||
create policy if not exists "Allow users update their device tokens" on public.fcm_tokens
|
||||
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
|
||||
Loading…
Reference in New Issue
Block a user