Files
frontend/src/services/notification/jpushService.ts
lafay f3d54a3f4c
Some checks failed
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 7m23s
feat(background): implement user consent mechanism for auto-start functionality
Add auto-start consent system that requires explicit user permission before enabling background sync services. Users can now choose between silent mode (no auto-start) or background mode (15-minute sync intervals).

- Add consent service with AutoStartMode enum and storage utilities
- Create withRemoveAutoStart Expo plugin to disable Android auto-start defaults
- Integrate consent checks into JPush service, background task manager, and foreground service
- Add auto-start consent UI in notification settings with descriptive dialog
- Update privacy policy with section 8 explaining auto-start scenarios and user controls
- Change default sync mode from BATTERY_SAVER to DISABLED
- Export reinitBackgroundService function for re-initializing after consent changes
- Merge OTA Android and iOS workflows into matrix build
- Add release signing config in withSigning plugin for Android
- Expand .dockerignore with native credential and build artifact exclusions
2026-06-15 20:43:15 +08:00

438 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Platform, NativeModules, AppState, Vibration } 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 || 'developer-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();
// 检查用户是否同意自启动,仅在同意后才保持后台长连接
const { isAutoStartAllowed } = await import('../consent/autoStartConsent');
const keepLongConn = isAutoStartAllowed();
this._setBackgroundKeepLongConn(keepLongConn);
// 初始化 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);
}
// Ensure notification channel has sound & vibration enabled via expo-notifications
try {
Notifications.setNotificationChannelAsync('jpush_notification', {
name: '推送通知',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
enableLights: true,
showBadge: true,
});
} catch (error) {
console.warn('[JPush] expo-notifications channel setup failed:', error);
}
}
/**
* 设置退后台是否保持极光长连接
* @see https://docs.jiguang.cn/jpush/client/Android/android_api#退后台是否保持极光长连接-api
*/
private _setBackgroundKeepLongConn(keep: boolean): void {
if (!JPush) return;
try {
if (Platform.OS === 'android') {
const jpushModule = NativeModules.JPushModule;
if (jpushModule?.setKeepLongConnInBackground) {
jpushModule.setKeepLongConnInBackground(keep);
console.log('[JPush] setKeepLongConnInBackground:', keep);
} else {
console.warn('[JPush] setKeepLongConnInBackground not available in native module');
}
} else if (Platform.OS === 'ios') {
JPush.setBackgroundEnable(keep);
console.log('[JPush] setBackgroundEnable:', keep);
}
} catch (error) {
console.warn('[JPush] setBackgroundKeepLongConn 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);
}
// JPush local notification does not support sound/vibration directly.
// Trigger them manually based on user preferences.
const prefs = getNotificationPreferencesSync();
if (prefs.soundEnabled) {
// Play system default notification sound via a silent expo-notification
Notifications.scheduleNotificationAsync({
content: { title: '', body: '', sound: true, vibrate: [] },
trigger: null,
}).catch(() => {});
}
if (prefs.vibrationEnabled) {
// Use React Native Vibration API for device-level vibration
Vibration.vibrate([0, 250, 250, 250]);
}
} 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;