diff --git a/packages/im/src/ImClient.ts b/packages/im/src/ImClient.ts index ca3b839..893eedf 100644 --- a/packages/im/src/ImClient.ts +++ b/packages/im/src/ImClient.ts @@ -110,6 +110,23 @@ export class ImClient { ) } + sync() { + if (this.ws?.readyState !== WebSocket.OPEN) return + if (!this.activeAppId) return + this.sendFrame( + 'SEND', + { + destination: '/app/chat.sync', + 'content-type': 'application/json', + }, + JSON.stringify({ appId: this.activeAppId }), + ) + } + + private sendSync() { + this.sync() + } + subscribeGroup(groupId: string) { const alreadySubscribed = this.groupSubscriptions.has(groupId) this.groupSubscriptions.add(groupId) @@ -204,6 +221,7 @@ export class ImClient { this.groupSubscriptions.forEach(groupId => { this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`) }) + this.sendSync() this.listeners.forEach(listener => listener.onConnected?.()) return } diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts index d26f067..296fbd4 100644 --- a/packages/im/src/ImSDK.ts +++ b/packages/im/src/ImSDK.ts @@ -701,6 +701,29 @@ export const ImSDK = { return msg }, + async syncOfflineMessages(maxCount = 100): Promise { + const config = getConfig() + const res = await apiRequest('/api/im/messages/offline', { + method: 'POST', + params: { appId: config.appId, maxCount: String(maxCount) }, + }) + const messages = Array.isArray(res) ? res : (res.data ?? []) + if (ImDatabase.isInitialized() && _currentUserId) { + for (const msg of messages) { + await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + } + } + return messages + }, + + async offlineMessageCount(): Promise { + const config = getConfig() + const res = await apiRequest<{ count: number }>('/api/im/messages/offline/count', { + params: { appId: config.appId }, + }) + return res.count ?? 0 + }, + async createGroup(name: string, memberIds: string[], groupType = 'WORK'): Promise { const config = getConfig() return apiRequest('/api/im/groups', { @@ -1212,6 +1235,90 @@ export const ImSDK = { return Array.isArray(res) ? res : (res.content ?? []) }, + async batchAddFriends(friendIds: string[]): Promise { + const config = getConfig() + await apiRequest('/api/im/friends/batch', { + method: 'POST', + params: { appId: config.appId }, + body: { friendIds }, + }) + }, + + async batchRemoveFriends(friendIds: string[]): Promise { + const config = getConfig() + await apiRequest('/api/im/friends/batch/remove', { + method: 'POST', + params: { appId: config.appId }, + body: { friendIds }, + }) + }, + + async batchAcceptFriendRequests(requestIds: string[]): Promise { + const config = getConfig() + await apiRequest('/api/im/friend-requests/batch/accept', { + method: 'POST', + params: { appId: config.appId }, + body: { requestIds }, + }) + }, + + async batchRejectFriendRequests(requestIds: string[]): Promise { + const config = getConfig() + await apiRequest('/api/im/friend-requests/batch/reject', { + method: 'POST', + params: { appId: config.appId }, + body: { requestIds }, + }) + }, + + async batchAddGroupMembers(groupId: string, userIds: string[]): Promise { + const config = getConfig() + await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/batch`, { + method: 'POST', + params: { appId: config.appId }, + body: { userIds }, + }) + }, + + async batchRemoveGroupMembers(groupId: string, userIds: string[]): Promise { + const config = getConfig() + await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/batch/remove`, { + method: 'POST', + params: { appId: config.appId }, + body: { userIds }, + }) + }, + + async batchAcceptGroupJoinRequests(groupId: string, requestIds: string[]): Promise { + const config = getConfig() + await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/batch/accept`, { + method: 'POST', + params: { appId: config.appId }, + body: { requestIds }, + }) + }, + + async batchRejectGroupJoinRequests(groupId: string, requestIds: string[]): Promise { + const config = getConfig() + await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/batch/reject`, { + method: 'POST', + params: { appId: config.appId }, + body: { requestIds }, + }) + }, + + async modifyGroupMemberInfo(groupId: string, userId: string, nickname?: string, role?: string): Promise { + const config = getConfig() + const body: Record = {} + if (nickname !== undefined) body.nickname = nickname + if (role !== undefined) body.role = role + await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}/info`, { + method: 'PUT', + params: { appId: config.appId }, + body, + }) + }, + async getDraft(targetId: string, chatType: ChatType): Promise { const config = getConfig() if (ImDatabase.isInitialized()) { diff --git a/packages/im/src/index.ts b/packages/im/src/index.ts index c787cf2..c5082c1 100644 --- a/packages/im/src/index.ts +++ b/packages/im/src/index.ts @@ -20,6 +20,8 @@ export const listConversationGroups = (): ReturnType => _ImSDK.listConversationGroupItems(groupName) export const locateHistoryPage = (toId: string, messageId: string, pageSize?: number, maxPages?: number): ReturnType => _ImSDK.locateHistoryPage(toId, messageId, pageSize, maxPages) export const locateGroupHistoryPage = (groupId: string, messageId: string, pageSize?: number, maxPages?: number): ReturnType => _ImSDK.locateGroupHistoryPage(groupId, messageId, pageSize, maxPages) +export const syncOfflineMessages = (maxCount?: number): ReturnType => _ImSDK.syncOfflineMessages(maxCount) +export const offlineMessageCount = (): ReturnType => _ImSDK.offlineMessageCount() export { ImClient } from './ImClient' export { ImDatabase } from './db/ImDatabase' export type { MessageSearchParams } from './db/ImDatabase' diff --git a/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.java b/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.java new file mode 100644 index 0000000..a1537f6 --- /dev/null +++ b/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.java @@ -0,0 +1,224 @@ +package com.xuqm.push; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Promise; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +import java.lang.reflect.Proxy; + +public class XuqmPushModule extends ReactContextBaseJavaModule { + + private static final String TAG = "XuqmPushModule"; + private static final String EVENT_TOKEN = "XuqmPushToken"; + + public XuqmPushModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @NonNull + @Override + public String getName() { + return "XuqmPushModule"; + } + + @ReactMethod + public void detectVendor(Promise promise) { + String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER.toUpperCase() : ""; + String vendor; + switch (manufacturer) { + case "HUAWEI": + vendor = "HUAWEI"; + break; + case "XIAOMI": + case "REDMI": + vendor = "XIAOMI"; + break; + case "HONOR": + vendor = "HONOR"; + break; + case "OPPO": + case "REALME": + vendor = "OPPO"; + break; + case "VIVO": + case "IQOO": + vendor = "VIVO"; + break; + default: + vendor = "FCM"; + break; + } + promise.resolve(vendor); + } + + @ReactMethod + public void registerPush(Promise promise) { + Context context = getReactApplicationContext(); + String vendor = resolveVendor(); + try { + switch (vendor) { + case "HUAWEI": + registerHuawei(context); + break; + case "XIAOMI": + registerXiaomi(context); + break; + case "OPPO": + registerOppo(context); + break; + case "VIVO": + registerVivo(context); + break; + case "HONOR": + registerHonor(context); + break; + default: + registerFcm(context); + break; + } + promise.resolve(null); + } catch (Exception e) { + Log.w(TAG, "registerPush failed: " + e.getMessage()); + promise.reject("PUSH_REGISTER_ERROR", e.getMessage(), e); + } + } + + private String resolveVendor() { + String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER.toUpperCase() : ""; + switch (manufacturer) { + case "HUAWEI": return "HUAWEI"; + case "XIAOMI": + case "REDMI": return "XIAOMI"; + case "HONOR": return "HONOR"; + case "OPPO": + case "REALME": return "OPPO"; + case "VIVO": + case "IQOO": return "VIVO"; + default: return "FCM"; + } + } + + private void emitToken(String token, String vendor) { + com.facebook.react.bridge.WritableMap map = new com.facebook.react.bridge.WritableNativeMap(); + map.putString("token", token); + map.putString("vendor", vendor); + getReactApplicationContext() + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(EVENT_TOKEN, map); + } + + // ── Vendor registrations (via reflection) ──────────────────────────────── + + private void registerHuawei(Context context) throws Exception { + Class hmsClass = Class.forName("com.huawei.hms.aaid.HmsInstanceId"); + Object instance = hmsClass.getMethod("getInstance", Context.class).invoke(null, context); + String appId = getHuaweiAppId(context); + if (appId.isEmpty()) { + Log.w(TAG, "Huawei appId not found"); + return; + } + Object token = hmsClass.getMethod("getToken", String.class, String.class) + .invoke(instance, appId, "HCM"); + if (token instanceof String && !((String) token).isEmpty()) { + emitToken((String) token, "HUAWEI"); + } + } + + private String getHuaweiAppId(Context context) { + try { + Class configClass = Class.forName("com.huawei.agconnect.config.AGConnectServicesConfig"); + Object config = configClass.getMethod("fromContext", Context.class).invoke(null, context); + return (String) configClass.getMethod("getString", String.class).invoke(config, "client/app_id"); + } catch (Exception e) { + return ""; + } + } + + private void registerXiaomi(Context context) throws Exception { + Class miPushClass = Class.forName("com.xiaomi.mipush.sdk.MiPushClient"); + Bundle meta = context.getPackageManager().getApplicationInfo( + context.getPackageName(), PackageManager.GET_META_DATA).metaData; + if (meta == null) return; + String appId = meta.getString("XUQM_XIAOMI_APP_ID", ""); + String appKey = meta.getString("XUQM_XIAOMI_APP_KEY", ""); + if (!appId.isEmpty() && !appKey.isEmpty()) { + miPushClass.getMethod("registerPush", Context.class, String.class, String.class) + .invoke(null, context, appId, appKey); + } else { + Log.w(TAG, "Xiaomi appId/appKey not configured in meta-data"); + } + } + + private void registerOppo(Context context) throws Exception { + Class pushManagerClass = Class.forName("com.heytap.mcssdk.PushManager"); + Object instance = pushManagerClass.getMethod("getInstance").invoke(null); + Bundle meta = context.getPackageManager().getApplicationInfo( + context.getPackageName(), PackageManager.GET_META_DATA).metaData; + if (meta == null) return; + String appKey = meta.getString("XUQM_OPPO_APP_KEY", ""); + String appSecret = meta.getString("XUQM_OPPO_APP_SECRET", ""); + if (appKey.isEmpty() || appSecret.isEmpty()) { + Log.w(TAG, "OPPO appKey/appSecret not configured in meta-data"); + return; + } + Class callbackClass = Class.forName("com.heytap.mcssdk.callback.PushCallback"); + Object proxy = Proxy.newProxyInstance( + callbackClass.getClassLoader(), + new Class[]{callbackClass}, + (proxy1, method, args) -> { + if ("onRegister".equals(method.getName()) && args != null && args.length > 1) { + String regId = args[1] instanceof String ? (String) args[1] : null; + if (regId != null && !regId.isEmpty()) { + emitToken(regId, "OPPO"); + } + } + return null; + } + ); + pushManagerClass.getMethod("register", Context.class, String.class, String.class, callbackClass) + .invoke(instance, context, appKey, appSecret, proxy); + } + + private void registerVivo(Context context) throws Exception { + Class pushClientClass = Class.forName("com.vivo.push.PushClient"); + Object instance = pushClientClass.getMethod("getInstance", Context.class).invoke(null, context); + pushClientClass.getMethod("initialize").invoke(instance); + } + + private void registerHonor(Context context) throws Exception { + Class honorPushClass = Class.forName("com.hihonor.push.sdk.HonorPushClient"); + Object client = honorPushClass.getMethod("getInstance").invoke(null); + honorPushClass.getMethod("init", Context.class, boolean.class).invoke(client, context, true); + } + + private void registerFcm(Context context) throws Exception { + Class fcmClass = Class.forName("com.google.firebase.messaging.FirebaseMessaging"); + Object instance = fcmClass.getMethod("getInstance").invoke(null); + Object task = fcmClass.getMethod("getToken").invoke(instance); + Class onSuccessClass = Class.forName("com.google.android.gms.tasks.OnSuccessListener"); + Object proxy = Proxy.newProxyInstance( + onSuccessClass.getClassLoader(), + new Class[]{onSuccessClass}, + (proxy1, method, args) -> { + if (args != null && args.length > 0 && args[0] instanceof String) { + String token = (String) args[0]; + if (!token.isEmpty()) { + emitToken(token, "FCM"); + } + } + return null; + } + ); + task.getClass().getMethod("addOnSuccessListener", onSuccessClass).invoke(task, proxy); + } +} diff --git a/packages/push/ios/XuqmPushModule.m b/packages/push/ios/XuqmPushModule.m new file mode 100644 index 0000000..7394aca --- /dev/null +++ b/packages/push/ios/XuqmPushModule.m @@ -0,0 +1,48 @@ +#import +#import + +@interface XuqmPushModule : RCTEventEmitter +@end + +@implementation XuqmPushModule + +RCT_EXPORT_MODULE(XuqmPushModule); + +- (NSArray *)supportedEvents { + return @[@"XuqmPushToken"]; +} + ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +RCT_EXPORT_METHOD(detectVendor:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + NSString *vendor = @"APNS"; + #if TARGET_OS_SIMULATOR + vendor = @"FCM"; + #endif + resolve(vendor); +} + +RCT_EXPORT_METHOD(registerPush:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_main_queue(), ^{ + [[UIApplication sharedApplication] registerForRemoteNotifications]; + resolve(@(YES)); + }); +} + +// Called by the AppDelegate to report the device token +- (void)didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { + const unsigned char *dataBuffer = (const unsigned char *)[deviceToken bytes]; + NSUInteger dataLength = [deviceToken length]; + NSMutableString *hexString = [NSMutableString stringWithCapacity:(dataLength * 2)]; + for (NSUInteger i = 0; i < dataLength; ++i) { + [hexString appendFormat:@"%02x", dataBuffer[i]]; + } + NSString *token = [hexString copy]; + [self sendEventWithName:@"XuqmPushToken" body:@{@"token": token, @"vendor": @"APNS"}]; +} + +@end diff --git a/packages/push/src/NativePush.ts b/packages/push/src/NativePush.ts new file mode 100644 index 0000000..5a1a2b3 --- /dev/null +++ b/packages/push/src/NativePush.ts @@ -0,0 +1,33 @@ +import { NativeModules, NativeEventEmitter, Platform } from 'react-native' + +interface XuqmPushModuleInterface { + detectVendor: () => Promise + registerPush: () => Promise +} + +const _native = NativeModules.XuqmPushModule as XuqmPushModuleInterface | undefined +const _eventEmitter = _native ? new NativeEventEmitter(_native as any) : null + +export function isNativePushAvailable(): boolean { + return !!_native +} + +export async function detectVendorNative(): Promise { + if (!_native?.detectVendor) return Platform.OS === 'ios' ? 'APNS' : 'FCM' + return _native.detectVendor() +} + +export async function registerPushNative(): Promise { + if (!_native?.registerPush) return + await _native.registerPush() +} + +export function addPushTokenListener( + callback: (event: { token: string; vendor: string }) => void, +): () => void { + if (!_eventEmitter) { + return () => {} + } + const subscription = _eventEmitter.addListener('XuqmPushToken', callback) + return () => subscription.remove() +} diff --git a/packages/push/src/PushSDK.ts b/packages/push/src/PushSDK.ts index 8cce310..4513cf1 100644 --- a/packages/push/src/PushSDK.ts +++ b/packages/push/src/PushSDK.ts @@ -1,5 +1,6 @@ import { apiRequest, getConfig, getDeviceInfo, getUserId as getCommonUserId } from '@xuqm/rn-common' import type { PushVendor } from '@xuqm/rn-common' +import { detectVendorNative, registerPushNative, addPushTokenListener } from './NativePush' export type { PushVendor } @@ -10,6 +11,7 @@ type PendingDeviceToken = { let currentUserId: string | null = null let pendingToken: PendingDeviceToken | null = null +let _tokenUnsubscribe: (() => void) | null = null async function registerPendingToken(): Promise { const userId = currentUserId ?? getCommonUserId() @@ -43,6 +45,36 @@ export const PushSDK = { await registerPendingToken() }, + /** + * Auto-detect vendor and request native push registration. + * On Android this attempts to register with the vendor SDK (Huawei, Xiaomi, OPPO, vivo, Honor, FCM). + * On iOS this requests APNs registration. + * Listen for token updates via onPushToken(callback). + */ + async requestNativeRegistration(): Promise { + await registerPushNative() + }, + + /** + * Listen for push tokens from the native layer. + * Call setDeviceToken(token, vendor) inside the callback to register with the server. + */ + onPushToken(callback: (token: string, vendor: string) => void): () => void { + if (_tokenUnsubscribe) { + _tokenUnsubscribe() + _tokenUnsubscribe = null + } + _tokenUnsubscribe = addPushTokenListener((event) => { + callback(event.token, event.vendor) + }) + return () => { + if (_tokenUnsubscribe) { + _tokenUnsubscribe() + _tokenUnsubscribe = null + } + } + }, + /** * Register a push device token for the given user. * If vendor is omitted, it is auto-detected from the device brand. diff --git a/packages/push/src/index.ts b/packages/push/src/index.ts index 35d022d..025237d 100644 --- a/packages/push/src/index.ts +++ b/packages/push/src/index.ts @@ -1,2 +1,3 @@ export { PushSDK } from './PushSDK' export type { PushVendor } from './PushSDK' +export { isNativePushAvailable, detectVendorNative, registerPushNative, addPushTokenListener } from './NativePush'