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
438 lines
13 KiB
TypeScript
438 lines
13 KiB
TypeScript
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; |