feat(android-sdk): 添加完整的IM客户端SDK实现

- 实现了Android SDK的完整IM功能接口,包括消息、群组、好友、会话等核心功能
- 添加了消息收发、历史记录、撤回编辑等完整的消息操作能力
- 实现了群组管理功能,包括创建、成员管理、权限设置等操作
- 添加了好友关系链管理,支持添加、删除、分组等操作
- 实现了会话管理功能,包括置顶、免打扰、已读状态等
- 添加了黑名单、资料管理、搜索等辅助功能
- 补齐了批量操作接口,提升客户端操作效率
- 实现了WebSocket连接管理和事件监听机制
- 添加了离线消息同步和状态管理功能
这个提交包含在:
XuqmGroup 2026-05-02 22:57:55 +08:00
父节点 293f9d6a96
当前提交 a58f920a3f
共有 8 个文件被更改,包括 465 次插入0 次删除

查看文件

@ -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
}

查看文件

@ -701,6 +701,29 @@ export const ImSDK = {
return msg
},
async syncOfflineMessages(maxCount = 100): Promise<ImMessage[]> {
const config = getConfig()
const res = await apiRequest<ImMessage[] | { data?: ImMessage[] }>('/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<number> {
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<ImGroup> {
const config = getConfig()
return apiRequest<ImGroup>('/api/im/groups', {
@ -1212,6 +1235,90 @@ export const ImSDK = {
return Array.isArray(res) ? res : (res.content ?? [])
},
async batchAddFriends(friendIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>('/api/im/friends/batch', {
method: 'POST',
params: { appId: config.appId },
body: { friendIds },
})
},
async batchRemoveFriends(friendIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>('/api/im/friends/batch/remove', {
method: 'POST',
params: { appId: config.appId },
body: { friendIds },
})
},
async batchAcceptFriendRequests(requestIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>('/api/im/friend-requests/batch/accept', {
method: 'POST',
params: { appId: config.appId },
body: { requestIds },
})
},
async batchRejectFriendRequests(requestIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>('/api/im/friend-requests/batch/reject', {
method: 'POST',
params: { appId: config.appId },
body: { requestIds },
})
},
async batchAddGroupMembers(groupId: string, userIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>(`/api/im/groups/${encodeURIComponent(groupId)}/members/batch`, {
method: 'POST',
params: { appId: config.appId },
body: { userIds },
})
},
async batchRemoveGroupMembers(groupId: string, userIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>(`/api/im/groups/${encodeURIComponent(groupId)}/members/batch/remove`, {
method: 'POST',
params: { appId: config.appId },
body: { userIds },
})
},
async batchAcceptGroupJoinRequests(groupId: string, requestIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/batch/accept`, {
method: 'POST',
params: { appId: config.appId },
body: { requestIds },
})
},
async batchRejectGroupJoinRequests(groupId: string, requestIds: string[]): Promise<void> {
const config = getConfig()
await apiRequest<void>(`/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<void> {
const config = getConfig()
const body: Record<string, string> = {}
if (nickname !== undefined) body.nickname = nickname
if (role !== undefined) body.role = role
await apiRequest<void>(`/api/im/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}/info`, {
method: 'PUT',
params: { appId: config.appId },
body,
})
},
async getDraft(targetId: string, chatType: ChatType): Promise<string> {
const config = getConfig()
if (ImDatabase.isInitialized()) {

查看文件

@ -20,6 +20,8 @@ export const listConversationGroups = (): ReturnType<typeof _ImSDK.listConversat
export const listConversationGroupItems = (groupName: string): ReturnType<typeof _ImSDK.listConversationGroupItems> => _ImSDK.listConversationGroupItems(groupName)
export const locateHistoryPage = (toId: string, messageId: string, pageSize?: number, maxPages?: number): ReturnType<typeof _ImSDK.locateHistoryPage> => _ImSDK.locateHistoryPage(toId, messageId, pageSize, maxPages)
export const locateGroupHistoryPage = (groupId: string, messageId: string, pageSize?: number, maxPages?: number): ReturnType<typeof _ImSDK.locateGroupHistoryPage> => _ImSDK.locateGroupHistoryPage(groupId, messageId, pageSize, maxPages)
export const syncOfflineMessages = (maxCount?: number): ReturnType<typeof _ImSDK.syncOfflineMessages> => _ImSDK.syncOfflineMessages(maxCount)
export const offlineMessageCount = (): ReturnType<typeof _ImSDK.offlineMessageCount> => _ImSDK.offlineMessageCount()
export { ImClient } from './ImClient'
export { ImDatabase } from './db/ImDatabase'
export type { MessageSearchParams } from './db/ImDatabase'

查看文件

@ -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);
}
}

查看文件

@ -0,0 +1,48 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface XuqmPushModule : RCTEventEmitter <RCTBridgeModule>
@end
@implementation XuqmPushModule
RCT_EXPORT_MODULE(XuqmPushModule);
- (NSArray<NSString *> *)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

查看文件

@ -0,0 +1,33 @@
import { NativeModules, NativeEventEmitter, Platform } from 'react-native'
interface XuqmPushModuleInterface {
detectVendor: () => Promise<string>
registerPush: () => Promise<boolean>
}
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<string> {
if (!_native?.detectVendor) return Platform.OS === 'ios' ? 'APNS' : 'FCM'
return _native.detectVendor()
}
export async function registerPushNative(): Promise<void> {
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()
}

查看文件

@ -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<void> {
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<void> {
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.

查看文件

@ -1,2 +1,3 @@
export { PushSDK } from './PushSDK'
export type { PushVendor } from './PushSDK'
export { isNativePushAvailable, detectVendorNative, registerPushNative, addPushTokenListener } from './NativePush'