Files
frontend/src/services/notification/jpushService.ts

394 lines
12 KiB
TypeScript
Raw Normal View History

import { Platform, NativeModules, AppState } from 'react-native';
import { pushService } from './pushService';
import * as Notifications from 'expo-notifications';
import { getNotificationPreferencesSync } from './notificationPreferences';
import { vibrateOnMessage } from '../platform/messageVibrationService';
type JPushNotificationMessage = {
messageID: string;
title: string;
content: string;
extras: Record<string, string>;
notificationType?: string;
notificationEventType?: 'notificationArrived' | 'notificationOpened';
};
type JPushCallback = (message: JPushNotificationMessage) => void;
let JPush: typeof import('jpush-react-native').default | null = null;
let jpushAppKey = '';
let jpushChannel = 'developer-default';
try {
const nativeModule = NativeModules.JPushModule;
if (nativeModule) {
JPush = require('jpush-react-native').default;
const Constants = require('expo-constants').default;
const extra = Constants?.expoConfig?.extra || Constants?.extra || {};
jpushAppKey = extra.jpushAppKey || '';
jpushChannel = extra.jpushChannel || 'withyou-default';
} else {
console.warn('[JPush] native module not linked, skipping');
}
} catch {
console.warn('[JPush] jpush-react-native not available');
}
function getAuthStore() {
return require('@/stores').useAuthStore;
}
class JPushService {
private registrationID: string | null = null;
private isInitialized = false;
private initPromise: Promise<boolean> | null = null;
private notificationCallback: JPushCallback | null = null;
private connectResolved = false;
private listenersRegistered = false;
async initialize(): Promise<boolean> {
if (this.isInitialized) return true;
if (Platform.OS === 'web') return false;
if (!JPush) return false;
if (this.initPromise) return this.initPromise;
this.initPromise = this._doInit();
return this.initPromise;
}
private _registerListeners(): void {
if (this.listenersRegistered) return;
this.listenersRegistered = true;
JPush!.addConnectEventListener((result: { connectEnable: boolean }) => {
console.log('[JPush] connect event:', result.connectEnable);
if (result.connectEnable && !this.connectResolved) {
this.connectResolved = true;
this._onConnected();
}
});
JPush!.addNotificationListener((result: any) => {
console.log('[JPush] notification:', result);
if (result.notificationEventType === 'notificationArrived') {
const prefs = getNotificationPreferencesSync();
if (prefs.vibrationEnabled && AppState.currentState === 'active') {
vibrateOnMessage('notification').catch(() => {});
}
}
if (this.notificationCallback) {
this.notificationCallback({
messageID: result.messageID || '',
title: result.title || '',
content: result.content || '',
extras: result.extras || {},
notificationType: result.extras?.notification_type,
notificationEventType: result.notificationEventType,
});
}
});
JPush!.addCustomMessageListener((result: any) => {
console.log('[JPush] custom message:', result);
});
JPush!.addLocalNotificationListener((result: any) => {
console.log('[JPush] local notification:', result);
});
JPush!.addTagAliasListener((result: any) => {
console.log('[JPush] tag/alias result:', result);
});
JPush!.addCommandEventListener((result: any) => {
console.log('[JPush] command result:', result);
});
}
private async _doInit(): Promise<boolean> {
try {
JPush!.setLoggerEnable(true);
// Register listeners only once (idempotent guard)
this._registerListeners();
// 初始化 SDK
JPush!.init({
appKey: jpushAppKey,
channel: jpushChannel,
production: !__DEV__,
});
if (Platform.OS === 'android') {
this._setupAndroidNotificationChannel();
}
console.log('[JPush] init called, waiting for registration...');
// 轮询获取 RegistrationID间隔逐渐增大
const regID = await this._pollRegistrationID();
if (regID) {
this.registrationID = regID;
this.isInitialized = true;
console.log('[JPush] initialized with registrationID:', regID);
await this.registerDeviceToServer();
return true;
}
// 即使没拿到 registrationID也标记为已初始化
// connect 事件回来后会自动获取
this.isInitialized = true;
console.warn('[JPush] init done but no registrationID yet, will retry on connect');
return true;
} catch (error) {
console.error('[JPush] initialization failed:', error);
this.initPromise = null;
return false;
}
}
private _onConnected(): void {
if (this.registrationID) return;
console.log('[JPush] connected, fetching registrationID...');
this._fetchRegistrationID();
}
private _setupAndroidNotificationChannel(): void {
if (!JPush) return;
try {
(JPush as any).setChannelAndSound({
channel: '推送通知',
channelId: 'jpush_notification',
});
console.log('[JPush] Android notification channel configured');
} catch (error) {
console.warn('[JPush] setChannelAndSound failed:', error);
}
try {
Notifications.setNotificationChannelAsync('jpush_notification', {
name: '推送通知',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
sound: 'default',
enableLights: true,
showBadge: true,
});
} catch (error) {
console.warn('[JPush] expo-notifications channel setup failed:', error);
}
}
private _fetchRegistrationID(): void {
if (!JPush) return;
JPush.getRegistrationID((result: any) => {
console.log('[JPush] getRegistrationID result:', JSON.stringify(result));
const regID = result?.registerID || result?.registrationID;
if (regID) {
this.registrationID = regID;
this.isInitialized = true;
console.log('[JPush] got registrationID:', regID);
this.registerDeviceToServer();
} else {
console.warn('[JPush] getRegistrationID returned empty');
}
});
}
private _pollRegistrationID(): Promise<string | null> {
return new Promise((resolve) => {
// 轮询策略: 2s, 4s, 6s, 8s, 10s (总计 30s)
const delays = [2000, 4000, 6000, 8000, 10000];
let attempt = 0;
const tryFetch = () => {
if (!JPush) {
resolve(null);
return;
}
JPush.getRegistrationID((result: any) => {
const regID = result?.registerID || result?.registrationID;
console.log(`[JPush] poll attempt ${attempt + 1}, regID:`, regID || '(empty)');
if (regID) {
resolve(regID);
return;
}
attempt++;
if (attempt < delays.length) {
setTimeout(tryFetch, delays[attempt] - (attempt > 0 ? delays[attempt - 1] : 0));
} else {
resolve(null);
}
});
};
setTimeout(tryFetch, delays[0]);
});
}
async registerDeviceToServer(): Promise<void> {
if (!this.registrationID) return;
const useAuthStore = getAuthStore();
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
try {
const deviceType = Platform.OS === 'ios' ? 'ios' : 'android';
const deviceID = await this.getDeviceID();
await pushService.registerDevice({
device_id: deviceID,
device_type: deviceType as 'ios' | 'android',
push_token: this.registrationID,
device_name: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} Device`,
});
console.log('[JPush] device registered to server');
} catch (error) {
console.error('[JPush] failed to register device to server:', error);
}
}
private async getDeviceID(): Promise<string> {
try {
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
const DEVICE_ID_KEY = 'withyou_device_id';
let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY);
if (deviceId) return deviceId;
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
deviceId = `${Platform.OS}_${timestamp}_${random}`;
await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId);
return deviceId;
} catch {
return `${Platform.OS}_${Date.now()}`;
}
}
onNotification(callback: JPushCallback): void {
this.notificationCallback = callback;
}
setTags(tags: string[]): void {
if (!JPush || !this.isInitialized) return;
try {
JPush.addTags({ sequence: Date.now(), tags });
} catch (error) {
console.error('[JPush] setTags failed:', error);
}
}
setAlias(alias: string): void {
if (!JPush || !this.isInitialized) return;
try {
JPush.setAlias({ sequence: Date.now(), alias });
} catch (error) {
console.error('[JPush] setAlias failed:', error);
}
}
async setAliasForUser(userID: string): Promise<void> {
this.setAlias(userID);
}
deleteAlias(): void {
if (!JPush || !this.isInitialized) return;
try {
JPush.deleteAlias({ sequence: Date.now() });
} catch (error) {
console.error('[JPush] deleteAlias failed:', error);
}
}
getRegistrationIDValue(): string | null {
return this.registrationID;
}
isJPushAvailable(): boolean {
return this.isInitialized && this.registrationID !== null;
}
addLocalNotification(params: {
messageID: string;
title: string;
content: string;
extras: Record<string, string>;
}): void {
if (!JPush || !this.isInitialized) return;
try {
if (Platform.OS === 'android') {
const localParams = {
...params,
priority: 1,
};
JPush.addLocalNotification(localParams);
} else {
JPush.addLocalNotification(params);
}
} catch (error) {
console.error('[JPush] addLocalNotification failed:', error);
}
}
clearAllNotifications(): void {
if (!JPush || !this.isInitialized) return;
try {
JPush.clearAllNotifications();
} catch (error) {
console.error('[JPush] clearAllNotifications failed:', error);
}
}
setBadge(badge: number): void {
if (!JPush || !this.isInitialized || Platform.OS !== 'ios') return;
try {
JPush.setBadge({ badge, appBadge: badge });
} catch (error) {
console.error('[JPush] setBadge failed:', error);
}
}
/**
* 使 expo-notifications iOS/Android
*/
async requestNotificationPermission(): Promise<boolean> {
if (Platform.OS === 'web') return false;
try {
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
} catch (error) {
console.warn('[JPush] request notification permission failed:', error);
return false;
}
}
/**
*
*/
async checkNotificationPermission(): Promise<boolean> {
if (Platform.OS === 'web') return false;
try {
const { status } = await Notifications.getPermissionsAsync();
return status === 'granted';
} catch {
return false;
}
}
cleanup(): void {
this.isInitialized = false;
this.registrationID = null;
this.notificationCallback = null;
this.initPromise = null;
this.connectResolved = false;
}
}
export const jpushService = new JPushService();
export default jpushService;