feat(notification): migrate from expo-notifications to JPush push service
Some checks failed
Frontend CI / build-and-push-web (push) Has started running
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled

Replace expo-notifications with JPush for push notifications across the app.
This includes adding JPush native module dependencies, configuring the JPush
plugin in app.json, implementing jpushService wrapper, and updating notification
services to use JPush APIs. Also adds device registration on token refresh for
both mobile and web platforms.

BREAKING CHANGE: expo-notifications removed in favor of JPush for push notifications
This commit is contained in:
lafay
2026-04-27 23:21:08 +08:00
parent 0e4306c3ac
commit ba89b3dc94
13 changed files with 690 additions and 163 deletions

View File

@@ -0,0 +1,318 @@
import { Platform, NativeModules } from 'react-native';
import { pushService } from './pushService';
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);
}
}
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;