- Add expo-notifications dependency and configure with JPush plugin - Request notification permission on app launch for iOS/Android - Handle notification taps to navigate to chat or notifications screen - Add UI to check and request system notification permission in settings - Refactor background sync: WebSocket disconnects in background, JPush handles push - Add checkNotificationPermission() and requestNotificationPermission() to jpushService
348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
import { Platform, NativeModules } from 'react-native';
|
||
import { pushService } from './pushService';
|
||
import * as Notifications from 'expo-notifications';
|
||
|
||
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 (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__,
|
||
});
|
||
|
||
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; // Already have registrationID, skip
|
||
console.log('[JPush] connected, fetching registrationID...');
|
||
this._fetchRegistrationID();
|
||
}
|
||
|
||
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 {
|
||
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; |