build(config): update bundle identifier and add signing plugin
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m33s
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / build-and-push-web (push) Successful in 2m37s
Frontend CI / build-android-apk (push) Successful in 1h22m51s

feat(notification): improve JPush integration and message sync reliability

- Update iOS bundle identifier to `cn.qczlit.weiyou`
- Add `withSigning` Expo plugin
- Enhance `withJPush` plugin with improved Swift AppDelegate injection logic
- Update JPush default channel to `developer-default`
- Optimize `MessageSyncService` to prevent race conditions in unread count updates
- Implement promise deduplication for `fetchUnreadCount` to prevent redundant network requests
- Add `setConversationsWithUnread` to `useMessageStore` for atomic state updates of conversations and unread counts
This commit is contained in:
2026-05-12 01:28:46 +08:00
parent 1d9c312c6c
commit 7c7aaf9108
8 changed files with 243 additions and 32 deletions

View File

@@ -25,7 +25,7 @@
],
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "cn.qczlit.withyou"
"bundleIdentifier": "cn.qczlit.weiyou"
},
"android": {
"adaptiveIcon": {
@@ -66,6 +66,7 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"./plugins/withSigning",
"./plugins/withMainActivityConfigChange",
[
"./plugins/withForegroundService",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -74,7 +74,116 @@ const withJPush = (config, options = {}) => {
config = withAppDelegate(config, (config) => {
const { contents } = config.modResults;
if (!contents.includes('JPush')) {
if (contents.includes('JPush') || contents.includes('JCoreModule')) {
return config;
}
const isSwift = contents.includes('class AppDelegate') || contents.includes('@main');
if (isSwift) {
let newContents = contents;
if (!newContents.includes('import JPushRN')) {
if (newContents.includes('import UIKit')) {
newContents = newContents.replace(
/(import UIKit\n)/,
`$1import UserNotifications\n`
);
} else {
newContents = newContents.replace(
/(internal import Expo\n)/,
`$1import UserNotifications\n`
);
}
}
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
// Match various Swift signatures
const swiftDidFinishPatterns = [
/public override func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\? = nil\s*\) -> Bool \{/,
/func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\?\) -> Bool \{/,
];
let didInsertInit = false;
for (const pattern of swiftDidFinishPatterns) {
if (pattern.test(newContents)) {
newContents = newContents.replace(
pattern,
`$&\n // JPush initialization\n #if DEBUG\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)\n #else\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)\n #endif`
);
didInsertInit = true;
break;
}
}
// Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace
if (!didInsertInit && newContents.includes('didFinishLaunchingWithOptions')) {
const lines = newContents.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) {
lines.splice(i + 1, 0,
' // JPush initialization',
' #if DEBUG',
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`,
' #else',
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`,
' #endif',
' UNUserNotificationCenter.current().delegate = self'
);
newContents = lines.join('\n');
didInsertInit = true;
break;
}
}
}
// Also add delegate = self after regex-based insertion if not already present
if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) {
newContents = newContents.replace(
/(#endif\n)(\s+let delegate)/,
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
);
}
// Swift: Add delegate methods before the closing brace of the class
const jpushSwiftMethods = `
// JPush: Register for remote notifications
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
JPUSHService.registerDeviceToken(deviceToken)
}
override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NSLog("did fail to register for remote notifications with error: %@", error.localizedDescription)
}
// JPush: Handle remote notification
override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
JPUSHService.handleRemoteNotification(userInfo)
completionHandler(.newData)
}
// UNUserNotificationCenter delegate: foreground notification display
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.alert, .badge, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
JPUSHService.handleRemoteNotification(response.notification.request.content.userInfo)
completionHandler()
}
`;
if (!newContents.includes('JPUSHService.registerDeviceToken')) {
// Find the closing brace of the AppDelegate class
const lastBraceIndex = newContents.lastIndexOf('}');
if (lastBraceIndex !== -1) {
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
}
}
config.modResults.contents = newContents;
} else {
// ObjC AppDelegate
let newContents = contents;
if (!newContents.includes('#import <UserNotifications/UserNotifications.h>')) {
@@ -95,7 +204,7 @@ const withJPush = (config, options = {}) => {
if (didFinishLaunchPattern.test(newContents)) {
newContents = newContents.replace(
didFinishLaunchPattern,
`$1\n // JPush initialization\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n`
`$1\n // JPush initialization\n #if DEBUG\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n #else\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:YES];\n #endif\n`
);
}
@@ -139,7 +248,26 @@ const withJPush = (config, options = {}) => {
return config;
});
// 4. iOS: Add required capabilities and permissions to Info.plist
// 4. iOS: Add bridging header for Swift if needed
config = withDangerousMod(config, [
'ios',
async (config) => {
const appDelegatePath = path.join(config.modRequest.platformProjectRoot, 'app', 'AppDelegate.swift');
if (fs.existsSync(appDelegatePath)) {
const bridgingHeaderSearch = path.join(config.modRequest.platformProjectRoot, 'app', 'app-Bridging-Header.h');
if (fs.existsSync(bridgingHeaderSearch)) {
let bridgingContent = fs.readFileSync(bridgingHeaderSearch, 'utf-8');
if (!bridgingContent.includes('JCoreModule')) {
bridgingContent += '\n#import "JPUSHService.h"\n#import <UserNotifications/UserNotifications.h>\n';
fs.writeFileSync(bridgingHeaderSearch, bridgingContent);
}
}
}
return config;
},
]);
// 5. iOS: Add required capabilities and permissions to Info.plist
config = withInfoPlist(config, (config) => {
config.modResults.UIBackgroundModes = config.modResults.UIBackgroundModes || [];
if (!config.modResults.UIBackgroundModes.includes('remote-notification')) {

41
plugins/withSigning.js Normal file
View File

@@ -0,0 +1,41 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const withSigning = (config, options = {}) => {
const { teamId = 'X3964MGXHG' } = options;
config = withDangerousMod(config, [
'ios',
async (config) => {
const platformRoot = config.modRequest.platformProjectRoot;
// 1. Inject CODE_SIGN_STYLE and DEVELOPMENT_TEAM into pbxproj
const pbxprojPath = path.join(platformRoot, 'app.xcodeproj', 'project.pbxproj');
if (fs.existsSync(pbxprojPath)) {
let contents = fs.readFileSync(pbxprojPath, 'utf-8');
const entitlementsLine = 'CODE_SIGN_ENTITLEMENTS = app/app.entitlements;';
const signingBlock = `${entitlementsLine}\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = ${teamId};`;
contents = contents.replaceAll(entitlementsLine, signingBlock);
fs.writeFileSync(pbxprojPath, contents);
}
// 2. Set aps-environment to production in entitlements
const entitlementsPath = path.join(platformRoot, 'app', 'app.entitlements');
if (fs.existsSync(entitlementsPath)) {
let entitlements = fs.readFileSync(entitlementsPath, 'utf-8');
entitlements = entitlements.replace(
/<key>aps-environment<\/key>\s*<string>development<\/string>/,
'<key>aps-environment</key>\n\t<string>production</string>'
);
fs.writeFileSync(entitlementsPath, entitlements);
}
return config;
},
]);
return config;
};
module.exports = withSigning;

View File

@@ -26,7 +26,7 @@ try {
const Constants = require('expo-constants').default;
const extra = Constants?.expoConfig?.extra || Constants?.extra || {};
jpushAppKey = extra.jpushAppKey || '';
jpushChannel = extra.jpushChannel || 'withyou-default';
jpushChannel = extra.jpushChannel || 'developer-default';
} else {
console.warn('[JPush] native module not linked, skipping');
}

View File

@@ -35,6 +35,8 @@ export class MessageSyncService implements IMessageSyncService {
private loadingMoreConversations = false;
/** 聊天页活跃期间延迟的会话列表刷新 */
private deferredConversationRefresh = false;
/** fetchUnreadCount 去重:复用 in-flight promise */
private fetchUnreadCountPromise: Promise<void> | null = null;
constructor(
getCurrentUserId: () => string | null,
@@ -108,15 +110,12 @@ export class MessageSyncService implements IMessageSyncService {
newConversations.set(normalizedConv.id, normalizedConv);
});
// 使用 store.setConversations 会自动排序并更新 conversationList
store.setConversations(newConversations);
// 计算并更新未读数
// 合并更新conversations + totalUnread 在同一次 set 中完成,消除中间状态窗口
const totalUnread = Array.from(newConversations.values()).reduce(
(sum, conv) => sum + (conv.unread_count || 0),
0
0,
);
store.setUnreadCount(totalUnread, store.getUnreadCount().system);
store.setConversationsWithUnread(newConversations, totalUnread, store.getUnreadCount().system);
this.persistConversationListCache();
} catch (error) {
@@ -360,11 +359,23 @@ export class MessageSyncService implements IMessageSyncService {
}
/**
* 获取未读数
* 获取未读数(去重:复用 in-flight promise
*/
async fetchUnreadCount(): Promise<void> {
if (this.fetchUnreadCountPromise) {
return this.fetchUnreadCountPromise;
}
this.fetchUnreadCountPromise = this.doFetchUnreadCount();
try {
await this.fetchUnreadCountPromise;
} finally {
this.fetchUnreadCountPromise = null;
}
}
private async doFetchUnreadCount(): Promise<void> {
const store = useMessageStore.getState();
try {
const [unreadData, systemUnreadData] = await Promise.all([
messageService.getUnreadCount(),
@@ -388,21 +399,28 @@ export class MessageSyncService implements IMessageSyncService {
}
}
// 服务端汇总未读为 0 时,清掉内存中残留的红点
// 服务端汇总未读为 0 时,仅在本地也一致时才清零(避免缓存竞态导致的抖动)
if (totalUnread === 0) {
const currentConversations = store.getState().conversations;
let anyCleared = false;
const newConversations = new Map(currentConversations);
for (const [cid, conv] of newConversations) {
if ((conv.unread_count || 0) > 0) {
newConversations.set(cid, { ...conv, unread_count: 0 });
anyCleared = true;
const localSum = Array.from(store.getState().conversations.values())
.reduce((sum, conv) => sum + (conv.unread_count || 0), 0);
if (localSum > 0) {
// 本地有未读但服务端返回 0 → 可能是后端缓存竞态,保留本地状态
// 下一次 fetchConversations 会提供权威的 per-conversation 数据
} else {
// 本地也无未读,确认清零
const currentConversations = store.getState().conversations;
let anyCleared = false;
const newConversations = new Map(currentConversations);
for (const [cid, conv] of newConversations) {
if ((conv.unread_count || 0) > 0) {
newConversations.set(cid, { ...conv, unread_count: 0 });
anyCleared = true;
}
}
if (anyCleared) {
store.setConversations(newConversations);
this.persistConversationListCache();
}
}
if (anyCleared) {
// 使用 zustand set 函数正确更新状态
store.setConversations(newConversations);
this.persistConversationListCache();
}
}
} catch (error) {
@@ -487,15 +505,12 @@ export class MessageSyncService implements IMessageSyncService {
newConversations.set(normalizedConv.id, normalizedConv);
});
// 使用 store.setConversations 会自动排序并更新 conversationList
store.setConversations(newConversations);
// 计算并更新未读数
// 合并更新conversations + totalUnread 在同一次 set 中完成
const totalUnread = Array.from(newConversations.values()).reduce(
(sum, conv) => sum + (conv.unread_count || 0),
0
0,
);
store.setUnreadCount(totalUnread, store.getUnreadCount().system);
store.setConversationsWithUnread(newConversations, totalUnread, store.getUnreadCount().system);
return true;
}

View File

@@ -79,6 +79,11 @@ export interface MessageActions {
// 设置状态
setConversations: (conversations: Map<string, ConversationResponse>) => void;
setConversationsWithUnread: (
conversations: Map<string, ConversationResponse>,
totalUnread: number,
systemUnread: number,
) => void;
updateConversation: (conversation: ConversationResponse) => void;
removeConversation: (conversationId: string) => void;
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
@@ -255,6 +260,27 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
}));
},
setConversationsWithUnread: (
conversations: Map<string, ConversationResponse>,
totalUnread: number,
systemUnread: number,
) => {
const conversationList = sortConversationList(conversations);
const notificationMutedMap = new Map<string, boolean>();
conversations.forEach((conv, id) => {
if (conv.notification_muted) {
notificationMutedMap.set(id, true);
}
});
set(state => ({
conversations,
conversationList,
notificationMutedMap: new Map([...state.notificationMutedMap, ...notificationMutedMap]),
totalUnreadCount: totalUnread,
systemUnreadCount: systemUnread,
}));
},
updateConversation: (conversation: ConversationResponse) => {
const id = normalizeConversationId(conversation.id);
set(state => {