From b7ce9e3b9a2db71b5220366646f8ba78b2886f6b Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 28 Apr 2026 00:20:07 +0800 Subject: [PATCH] feat(notification): integrate expo-notifications for permission handling and deep linking - 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 --- app.json | 11 +- app/_layout.tsx | 38 ++++- package-lock.json | 55 +++++-- package.json | 1 + .../profile/NotificationSettingsScreen.tsx | 40 +++++ .../background/BackgroundSyncManager.ts | 143 +++--------------- src/services/core/wsService.ts | 7 + src/services/notification/jpushService.ts | 30 ++++ 8 files changed, 188 insertions(+), 137 deletions(-) diff --git a/app.json b/app.json index 48afcc9..c3f4b6d 100644 --- a/app.json +++ b/app.json @@ -58,7 +58,8 @@ "android.permission.READ_MEDIA_VIDEO", "android.permission.READ_MEDIA_AUDIO", "android.permission.ACCESS_NETWORK_STATE", - "android.permission.ACCESS_WIFI_STATE" + "android.permission.ACCESS_WIFI_STATE", + "android.permission.POST_NOTIFICATIONS" ] }, "web": { @@ -136,6 +137,14 @@ } ], "expo-font", + [ + "expo-notifications", + { + "color": "#E6F4FE", + "defaultChannel": "default", + "enableBackgroundRemoteNotifications": true + } + ], [ "./plugins/withJPush", { diff --git a/app/_layout.tsx b/app/_layout.tsx index 0f913ef..54b2c07 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -128,6 +128,8 @@ function SessionGate({ children }: { children: React.ReactNode }) { function NotificationBootstrap() { const appState = useRef(AppState.currentState); + const permissionRequested = useRef(false); + const router = useRouter(); useEffect(() => { const initNotifications = async () => { @@ -145,9 +147,24 @@ function NotificationBootstrap() { appState.current = nextAppState; }); - // Listen for JPush notification taps + // Listen for JPush notification taps — navigate to the relevant screen jpushService.onNotification((message) => { - console.log('[NotificationBootstrap] JPush notification tapped:', message); + console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message); + if (message.notificationEventType !== 'notificationOpened') return; + + const extras = message.extras || {}; + const notifType = extras.notification_type || message.notificationType; + + if (notifType === 'chat_message' && extras.conversation_id) { + const isGroup = extras.conversation_type === 'group'; + router.push( + `/chat/${encodeURIComponent(extras.conversation_id)}${ + isGroup ? '?isGroupChat=1' : extras.sender_id ? `?userId=${encodeURIComponent(extras.sender_id)}` : '' + }` + ); + } else { + router.push('/messages/notifications'); + } }); return () => { @@ -155,7 +172,24 @@ function NotificationBootstrap() { }; }; + const requestPermissionOnLaunch = async () => { + if (permissionRequested.current) return; + permissionRequested.current = true; + + if (Platform.OS === 'web') return; + + try { + const hasPermission = await jpushService.checkNotificationPermission(); + if (!hasPermission) { + await jpushService.requestNotificationPermission(); + } + } catch (error) { + console.warn('[NotificationBootstrap] request permission on launch failed:', error); + } + }; + initNotifications(); + requestPermissionOnLaunch(); }, []); return null; diff --git a/package-lock.json b/package-lock.json index e9c11c6..d331536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "^55.0.8", "expo-media-library": "~55.0.9", + "expo-notifications": "~55.0.0", "expo-router": "^55.0.4", "expo-sqlite": "~55.0.10", "expo-status-bar": "~55.0.4", @@ -1678,17 +1679,17 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.8.12", - "resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.12.tgz", - "integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==", + "version": "0.8.13", + "resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.13.tgz", + "integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==", "license": "MIT", "dependencies": { + "@expo/require-utils": "^55.0.4", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", - "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, @@ -2053,9 +2054,9 @@ } }, "node_modules/@expo/require-utils": { - "version": "55.0.3", - "resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.3.tgz", - "integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==", + "version": "55.0.4", + "resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.4.tgz", + "integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -4358,6 +4359,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5517,6 +5524,15 @@ } } }, + "node_modules/expo-application": { + "version": "55.0.14", + "resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.14.tgz", + "integrity": "sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "55.0.10", "resolved": "https://registry.npmmirror.com/expo-asset/-/expo-asset-55.0.10.tgz", @@ -5565,12 +5581,11 @@ } }, "node_modules/expo-constants": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.9.tgz", - "integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==", + "version": "55.0.15", + "resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.15.tgz", + "integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.10", "@expo/env": "~2.1.1" }, "peerDependencies": { @@ -5831,6 +5846,24 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "55.0.20", + "resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.20.tgz", + "integrity": "sha512-ENwHZtr2ApR4VaqwwYluEi+ocip2rIkZfHQVi263fZXW3WWVuPa+VxWKtT0KLcvWYGld8lEqwAHWmFWPS6aG7A==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.13", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~55.0.14", + "expo-constants": "~55.0.15" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "55.0.7", "resolved": "https://registry.npmmirror.com/expo-router/-/expo-router-55.0.7.tgz", diff --git a/package.json b/package.json index cf642d0..0286628 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "^55.0.8", "expo-media-library": "~55.0.9", + "expo-notifications": "~55.0.0", "expo-router": "^55.0.4", "expo-sqlite": "~55.0.10", "expo-status-bar": "~55.0.4", diff --git a/src/screens/profile/NotificationSettingsScreen.tsx b/src/screens/profile/NotificationSettingsScreen.tsx index 16df45d..22847a5 100644 --- a/src/screens/profile/NotificationSettingsScreen.tsx +++ b/src/screens/profile/NotificationSettingsScreen.tsx @@ -12,6 +12,7 @@ import { ScrollView, Alert, TouchableOpacity, + Platform, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -22,6 +23,7 @@ import { setPushNotificationsPreference, setSoundPreference, setVibrationPreference, + jpushService, } from '@/services/notification'; import { useResponsive, useResponsiveSpacing } from '../../hooks'; import { backgroundSyncManager, BackgroundSyncMode } from '@/services/background'; @@ -45,6 +47,7 @@ export const NotificationSettingsScreen: React.FC = () => { const [pushEnabled, setPushEnabled] = useState(true); const [soundEnabled, setSoundEnabled] = useState(true); const [syncMode, setSyncMode] = useState(BackgroundSyncMode.BATTERY_SAVER); + const [systemPushEnabled, setSystemPushEnabled] = useState(null); const { isWideScreen, isMobile } = useResponsive(); const insets = useSafeAreaInsets(); @@ -60,6 +63,11 @@ export const NotificationSettingsScreen: React.FC = () => { setPushEnabled(prefs.pushEnabled); setSoundEnabled(prefs.soundEnabled); setSyncMode(backgroundSyncManager.getMode()); + + if (Platform.OS !== 'web') { + const enabled = await jpushService.checkNotificationPermission(); + setSystemPushEnabled(enabled); + } } catch (error) { console.error('加载通知设置失败:', error); } @@ -139,6 +147,21 @@ export const NotificationSettingsScreen: React.FC = () => { }, ]; + const handleRequestSystemPermission = async () => { + if (Platform.OS === 'web') return; + const granted = await jpushService.requestNotificationPermission(); + setSystemPushEnabled(granted); + if (!granted) { + Alert.alert( + '通知权限未开启', + '请在系统设置中手动开启通知权限,以便及时接收消息提醒。', + [ + { text: '知道了', style: 'cancel' }, + ] + ); + } + }; + const settings: NotificationSettingItem[] = [ { key: 'push', @@ -207,6 +230,23 @@ export const NotificationSettingsScreen: React.FC = () => { ))} + {/* 系统通知权限状态(仅移动端) */} + {Platform.OS !== 'web' && systemPushEnabled === false && ( + + + + + 系统通知权限未开启,点击前往开启 + + + + + )} + {/* 提示信息 */} diff --git a/src/services/background/BackgroundSyncManager.ts b/src/services/background/BackgroundSyncManager.ts index abe6bfe..7e0b726 100644 --- a/src/services/background/BackgroundSyncManager.ts +++ b/src/services/background/BackgroundSyncManager.ts @@ -1,23 +1,26 @@ /** * 后台同步管理器 * - * 整合前台服务、后台同步、消息状态管理 - * 参考 Element Android 的 BackgroundSyncStarter 设计 + * 后台推送策略: + * - 前台:WebSocket 实时通信 + * - 后台:断开 WebSocket,由 JPush 负责推送通知 + * - 回到前台:重连 WebSocket + 同步离线消息 + * + * 前台服务仅用于通话保活,不再用于消息同步。 */ import { AppState, AppStateStatus, Platform } from 'react-native'; import { ForegroundServiceModule } from './ForegroundServiceModule'; -import { wsService } from '../core/wsService'; import AsyncStorage from '@react-native-async-storage/async-storage'; /** * 后台同步模式 */ export enum BackgroundSyncMode { - /** 省电模式:使用 expo-background-task(最小15分钟间隔) */ + /** 省电模式:仅依赖 JPush 推送 */ BATTERY_SAVER = 'battery_saver', - /** 实时模式:前台服务保活 + 持续同步 */ + /** 实时模式:前台服务保活(仅用于通话) */ REALTIME = 'realtime', /** 禁用后台同步 */ @@ -25,12 +28,10 @@ export enum BackgroundSyncMode { } const STORAGE_KEY = 'background_sync_mode'; -const SYNC_INTERVAL_MS = 30 * 1000; // 30 秒同步间隔 class BackgroundSyncManager { private mode: BackgroundSyncMode = BackgroundSyncMode.BATTERY_SAVER; private appStateSubscription: ReturnType | null = null; - private syncInterval: ReturnType | null = null; private isInitialized: boolean = false; private lastSyncAt: number = 0; private syncMessagesCallback: (() => Promise) | null = null; @@ -49,19 +50,14 @@ class BackgroundSyncManager { async initialize(): Promise { if (this.isInitialized) return; - // 加载保存的模式 const savedMode = await this.loadMode(); this.mode = savedMode; - // 监听 AppState 变化 this.appStateSubscription = AppState.addEventListener( 'change', this.handleAppStateChange ); - // 监听 WebSocket 断开 - this.setupWSDisconnectListener(); - this.isInitialized = true; console.log('[BackgroundSyncManager] 初始化完成, 模式:', this.mode); } @@ -70,31 +66,19 @@ class BackgroundSyncManager { * 切换后台同步模式 */ async setMode(mode: BackgroundSyncMode): Promise { - // 如果当前在后台,先停止旧模式的保活 - if (AppState.currentState !== 'active') { - this.stopPeriodicSync(); - if (this.mode === BackgroundSyncMode.REALTIME) { - await ForegroundServiceModule.stop(); - } + if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) { + await ForegroundServiceModule.stop(); } - // 更新模式 this.mode = mode; await this.saveMode(mode); - // 根据新模式立即执行相应操作 if (mode === BackgroundSyncMode.REALTIME) { - // 切换到实时模式:立即启动前台服务(无论前台还是后台) - const started = await ForegroundServiceModule.start({ + await ForegroundServiceModule.start({ title: '威友', - body: '正在后台同步消息', + body: '正在后台运行', }); - if (started) { - this.startPeriodicSync(); - } } else { - // 切换到其他模式:立即停止前台服务和定时同步 - this.stopPeriodicSync(); await ForegroundServiceModule.stop(); } @@ -114,110 +98,30 @@ class BackgroundSyncManager { private handleAppStateChange = async (nextState: AppStateStatus) => { console.log('[BackgroundSyncManager] AppState 变化:', nextState); - if (nextState === 'background' || nextState === 'inactive') { - // 进入后台 - await this.startForBackground(); - } else if (nextState === 'active') { - // 回到前台 - await this.stopForForeground(); + if (nextState === 'active') { + await this.onForeground(); } }; - /** - * 进入后台时的处理 - */ - private async startForBackground(): Promise { - if (this.mode === BackgroundSyncMode.DISABLED) return; - - console.log('[BackgroundSyncManager] 进入后台'); - - if (this.mode === BackgroundSyncMode.REALTIME) { - // 确保前台服务正在运行 - await ForegroundServiceModule.start({ - title: '威友', - body: '正在后台同步消息', - }); - - // 启动定时同步 - this.startPeriodicSync(); - } - } - /** * 回到前台时的处理 + * 同步离线期间通过 JPush 推送的消息 */ - private async stopForForeground(): Promise { - console.log('[BackgroundSyncManager] 回到前台'); - - // 停止定时同步(前台通过 WebSocket 实时同步,不需要轮询) - this.stopPeriodicSync(); - - // 实时模式下:前台服务保持运行,只更新通知文字 - if (this.mode === BackgroundSyncMode.REALTIME) { - await ForegroundServiceModule.update('威友', '正在后台同步消息'); - } - - // 立即同步一次(获取离线消息) + private async onForeground(): Promise { + console.log('[BackgroundSyncManager] 回到前台,同步离线消息'); await this.syncMessages(); } - /** - * 启动定时同步 - */ - private startPeriodicSync(): void { - this.stopPeriodicSync(); - - // 立即同步一次 - this.syncMessages(); - - // 启动定时器 - this.syncInterval = setInterval(() => { - this.syncMessages(); - }, SYNC_INTERVAL_MS); - - console.log('[BackgroundSyncManager] 定时同步已启动'); - } - - /** - * 停止定时同步 - */ - private stopPeriodicSync(): void { - if (this.syncInterval) { - clearInterval(this.syncInterval); - this.syncInterval = null; - console.log('[BackgroundSyncManager] 定时同步已停止'); - } - } - - /** - * 设置 WebSocket 断开监听 - */ - private setupWSDisconnectListener(): void { - // 监听 WebSocket 断开事件 - wsService.onDisconnect(() => { - if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) { - console.log('[BackgroundSyncManager] WebSocket 断开,尝试重连'); - wsService.connect().catch((e) => { - console.error('[BackgroundSyncManager] WebSocket 重连失败:', e); - }); - } - }); - } - /** * 执行消息同步 */ async syncMessages(): Promise { const now = Date.now(); - - // 防止短时间内重复同步(最小 10 秒间隔) - if (now - this.lastSyncAt < 10000) { + if (now - this.lastSyncAt < 5000) { return; } - this.lastSyncAt = now; - // 调用外部注入的同步回调 if (this.syncMessagesCallback) { try { await this.syncMessagesCallback(); @@ -228,7 +132,7 @@ class BackgroundSyncManager { } /** - * 更新前台服务通知内容 + * 更新前台服务通知内容(仅通话场景使用) */ async updateNotification(totalUnread: number): Promise { if (this.mode !== BackgroundSyncMode.REALTIME) return; @@ -241,14 +145,11 @@ class BackgroundSyncManager { } else { await ForegroundServiceModule.update( '威友', - '正在后台同步消息' + '正在后台运行' ); } } - /** - * 保存模式到存储 - */ private async saveMode(mode: BackgroundSyncMode): Promise { try { await AsyncStorage.setItem(STORAGE_KEY, mode); @@ -257,9 +158,6 @@ class BackgroundSyncManager { } } - /** - * 从存储加载模式 - */ private async loadMode(): Promise { try { const saved = await AsyncStorage.getItem(STORAGE_KEY); @@ -273,7 +171,6 @@ class BackgroundSyncManager { * 清理资源 */ async cleanup(): Promise { - this.stopPeriodicSync(); this.appStateSubscription?.remove(); this.appStateSubscription = null; await ForegroundServiceModule.stop(); diff --git a/src/services/core/wsService.ts b/src/services/core/wsService.ts index ec60b94..fc07fb1 100644 --- a/src/services/core/wsService.ts +++ b/src/services/core/wsService.ts @@ -1101,7 +1101,14 @@ class WebSocketService { if (this.appStateSubscription) return; this.lastAppState = AppState.currentState; this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { + // 进入后台时断开 WebSocket,由 JPush 负责后台推送通知 + if (this.lastAppState === 'active' && nextState.match(/inactive|background/)) { + console.log('[WebSocket] App entering background, disconnecting'); + this.disconnect(); + } + // 回到前台时重连 WebSocket if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) { + console.log('[WebSocket] App returning to foreground, reconnecting'); this.reconnectAttempts = 0; this.connect(); } diff --git a/src/services/notification/jpushService.ts b/src/services/notification/jpushService.ts index a2e8f50..e40ac83 100644 --- a/src/services/notification/jpushService.ts +++ b/src/services/notification/jpushService.ts @@ -1,5 +1,6 @@ import { Platform, NativeModules } from 'react-native'; import { pushService } from './pushService'; +import * as Notifications from 'expo-notifications'; type JPushNotificationMessage = { messageID: string; @@ -305,6 +306,35 @@ class JPushService { } } + /** + * 请求通知权限(使用 expo-notifications,统一处理 iOS/Android) + */ + async requestNotificationPermission(): Promise { + 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 { + 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;