比较提交

...

4 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
4677717343 feat(android): add xuqm_release Gradle task, expand IM SDK with friends/groups/conversations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:36:51 +08:00
XuqmGroup
18f4c99b71 feat(chat): 添加聊天界面视图模型和联系人管理功能
- 实现 ChatViewModel 处理消息收发、历史记录加载和状态管理
- 添加消息搜索、草稿保存、引用回复等功能
- 实现多媒体附件发送包括图片、视频、音频和文件
- 添加群组提及用户功能和消息撤回机制
- 实现联系人管理功能包括好友搜索、添加、删除和黑名单管理
- 添加好友请求处理和实时消息监听
- 实现会话列表管理包含未读消息统计和实时更新
- 集成 IM SDK 的连接状态管理和事件监听
- 添加消息状态跟踪和超时处理机制
- 实现数据缓存机制优化用户体验
2026-04-28 22:32:20 +08:00
XuqmGroup
17168dcf4e feat(im): 添加即时通讯SDK核心功能
- 实现IM API接口定义,包括消息、群组、好友、黑名单等功能
- 定义IM消息相关数据模型,包含聊天类型、消息类型、用户资料等
- 实现ImSDK单例类,提供登录、消息发送、群组管理、好友管理等核心功能
- 添加WebSocket连接管理,支持自动重连机制
- 实现历史消息查询、群组操作、用户资料管理等API调用
- 添加会话状态管理,支持置顶、静音、草稿等功能
- 集成文件上传结果,支持多媒体消息发送
- 实现连接状态监听和事件回调机制
2026-04-28 21:05:06 +08:00
XuqmGroup
0425c988ae feat(chat): 添加聊天界面和文件更新SDK功能
- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型
- 集成IM消息收发功能,实现消息气泡显示和用户头像占位符
- 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像
- 实现语音录制和播放功能,包含按住说话交互和权限处理
- 添加群组提及功能,支持@用户和提及候选列表显示
- 实现消息回复和引用功能,支持消息长按回复操作
- 添加本地消息搜索功能,支持搜索当前会话的历史消息
- 实现文件上传下载功能,集成FileSDK进行文件传输管理
- 添加应用更新检查功能,集成UpdateSDK支持版本更新
- 实现消息状态显示,包括发送、送达、已读等状态标识
- 添加群组已读人数统计,显示消息在群聊中的阅读情况
- 实现草稿保存和恢复功能,支持断点续聊体验
- 添加连接状态横幅,实时显示IM服务连接状态
- 实现滚动加载更多历史消息,优化大量消息的性能表现
- 添加多媒体文件下载保存功能,支持保存到应用专属目录
2026-04-28 20:11:38 +08:00
共有 17 个文件被更改,包括 590 次插入41 次删除

查看文件

@ -34,6 +34,17 @@ class LocalImCache(context: Context) {
saveConversations(updated)
}
fun markConversationRead(targetId: String, chatType: String) {
val updated = loadConversations().map { conversation ->
if (conversation.targetId == targetId && conversation.chatType == chatType) {
conversation.copy(unreadCount = 0)
} else {
conversation
}
}
saveConversations(updated)
}
fun loadHistory(targetId: String, chatType: String): List<ImMessage> =
readMessageList(historyKey(targetId, chatType))
@ -133,7 +144,10 @@ class LocalImCache(context: Context) {
content = obj.optString("content"),
status = obj.optString("status"),
mentionedUserIds = obj.optString("mentionedUserIds").takeIf { it.isNotBlank() },
groupReadCount = obj.optInt("groupReadCount").takeIf { obj.has("groupReadCount") },
revoked = obj.optBoolean("revoked").takeIf { obj.has("revoked") },
createdAt = obj.optLong("createdAt"),
editedAt = obj.optLong("editedAt").takeIf { obj.has("editedAt") },
)
)
}
@ -174,7 +188,10 @@ class LocalImCache(context: Context) {
put("content", message.content)
put("status", message.status)
put("mentionedUserIds", message.mentionedUserIds ?: "")
message.groupReadCount?.let { put("groupReadCount", it) }
put("revoked", message.revoked ?: false)
put("createdAt", message.createdAt)
message.editedAt?.let { put("editedAt", it) }
}
)
}

查看文件

@ -44,6 +44,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
@ -68,10 +69,10 @@ import com.xuqm.sdk.ui.SearchBarField
import coil3.compose.AsyncImage
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import com.xuqm.sdk.file.FileSDK
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
@ -91,6 +92,7 @@ fun ChatScreen(
val draftText by viewModel.draftText.collectAsStateWithLifecycle()
val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle()
val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle()
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
@ -103,6 +105,7 @@ fun ChatScreen(
var draftValue by remember { mutableStateOf(TextFieldValue(draftText)) }
var showSearchBar by remember { mutableStateOf(false) }
val replyTarget = replyTargetMessage
val editingTarget = editingMessage
var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) }
var pendingCaptureUri by remember { mutableStateOf<Uri?>(null) }
var isRecordingVoice by remember { mutableStateOf(false) }
@ -299,6 +302,23 @@ fun ChatScreen(
}
}
}
if (editingTarget != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "编辑: ${editingTarget.previewText()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f),
)
TextButton(onClick = viewModel::clearEdit) {
Text("取消")
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@ -426,6 +446,7 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
onEdit = viewModel::startEdit,
)
}
if (searchResults.isEmpty()) {
@ -455,6 +476,7 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
onEdit = viewModel::startEdit,
)
}
if (isLoadingMore) {
@ -512,8 +534,11 @@ private fun MessageBubble(
isOwn: Boolean,
currentUserId: String,
onReply: (ImMessage) -> Unit,
onEdit: (ImMessage) -> Unit,
) {
val media = message.mediaContent()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val arrangement = if (isOwn) Arrangement.End else Arrangement.Start
val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
@ -542,9 +567,15 @@ private fun MessageBubble(
.padding(horizontal = 4.dp)
.combinedClickable(
onClick = {},
onLongClick = { onReply(message) },
onLongClick = {
if (isOwn && message.msgType.uppercase() == "TEXT") {
onEdit(message)
} else {
onReply(message)
}
},
),
) {
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
when (message.msgType.uppercase()) {
"IMAGE" -> ImageBubble(media)
@ -557,6 +588,27 @@ private fun MessageBubble(
style = MaterialTheme.typography.bodyMedium,
)
}
if (media?.url.isNullOrBlank().not() && message.msgType.uppercase() in setOf("IMAGE", "VIDEO", "AUDIO", "FILE")) {
TextButton(
onClick = {
scope.launch {
runCatching {
val saved = FileSDK.downloadToAppFiles(
context = context,
downloadUrl = media.url,
fileName = media.name?.takeIf { it.isNotBlank() } ?: defaultDownloadFileName(message),
directoryName = "demo-downloads",
)
Toast.makeText(context, "已保存到 ${saved.absolutePath}", Toast.LENGTH_SHORT).show()
}.onFailure {
Toast.makeText(context, "下载失败:${it.message ?: "未知错误"}", Toast.LENGTH_SHORT).show()
}
}
},
) {
Text("下载")
}
}
val mentionedUserIds = message.mentionedUserIds.orEmpty()
if (mentionedUserIds.isNotBlank() &&
mentionedUserIds.split(",").map { it.trim() }.contains(currentUserId)
@ -765,6 +817,17 @@ private fun formatDuration(ms: Long): String {
return if (minutes > 0) String.format("%d:%02d", minutes, seconds) else "${seconds}s"
}
private fun defaultDownloadFileName(message: ImMessage): String {
val suffix = when (message.msgType.uppercase()) {
"IMAGE" -> "jpg"
"VIDEO" -> "mp4"
"AUDIO" -> "m4a"
"FILE" -> "bin"
else -> "dat"
}
return "${message.msgType.lowercase()}_${message.id}.$suffix"
}
private fun mentionQueryAtCursor(value: TextFieldValue): String? {
if (!value.selection.collapsed) return null
val cursor = value.selection.end.coerceIn(0, value.text.length)

查看文件

@ -46,6 +46,9 @@ class ChatViewModel : ViewModel() {
private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null)
val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage
private val _editingMessage = MutableStateFlow<ImMessage?>(null)
val editingMessage: StateFlow<ImMessage?> = _editingMessage
private val _mentionableUsers = MutableStateFlow<List<UserData>>(emptyList())
val mentionableUsers: StateFlow<List<UserData>> = _mentionableUsers
@ -71,6 +74,14 @@ class ChatViewModel : ViewModel() {
override fun onGroupMessage(message: ImMessage) {
handleIncomingMessage(message)
}
override fun onRead(message: ImMessage) {
handleIncomingMessage(message)
}
override fun onRevoke(message: ImMessage) {
handleIncomingMessage(message)
}
}
fun init(targetId: String, chatType: String) {
@ -90,6 +101,7 @@ class ChatViewModel : ViewModel() {
_draftText.value = cache.loadDraft(targetId, chatType)
_mentionableUsers.value = emptyList()
_replyTargetMessage.value = null
_editingMessage.value = null
ImSDK.addListener(listener)
if (chatType == "GROUP") {
ImSDK.subscribeGroup(targetId)
@ -100,6 +112,10 @@ class ChatViewModel : ViewModel() {
}
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
.onSuccess {
cache.markConversationRead(targetId, chatType)
AppDependencies.notifyConversationChanged()
}
}
}
@ -170,6 +186,25 @@ class ChatViewModel : ViewModel() {
fun sendText(content: String) {
if (content.isBlank()) return
val editingTarget = _editingMessage.value
if (editingTarget != null) {
viewModelScope.launch {
runCatching { ImSDK.editMessage(editingTarget.id, content) }
.onSuccess { updated ->
handleIncomingMessage(updated)
_editingMessage.value = null
_replyTargetMessage.value = null
_draftText.value = ""
cache.clearDraft(targetId, chatType)
}
.onFailure {
_events.tryEmit("消息编辑失败,请检查网络后重试")
Log.w(TAG, "editMessage failed messageId=${editingTarget.id}", it)
}
}
return
}
val replyTarget = _replyTargetMessage.value
val sent = if (replyTarget != null) {
ImSDK.sendQuoteMessage(
@ -190,6 +225,7 @@ class ChatViewModel : ViewModel() {
_draftText.value = ""
cache.clearDraft(targetId, chatType)
_replyTargetMessage.value = null
_editingMessage.value = null
}
}
@ -217,6 +253,28 @@ class ChatViewModel : ViewModel() {
_replyTargetMessage.value = null
}
fun startEdit(message: ImMessage) {
_editingMessage.value = message
_replyTargetMessage.value = null
updateDraft(message.textContent())
}
fun clearEdit() {
_editingMessage.value = null
}
fun revokeMessage(messageId: String) {
if (!initialized) return
viewModelScope.launch {
runCatching { ImSDK.revokeMessage(messageId) }
.onSuccess { revoked -> handleIncomingMessage(revoked) }
.onFailure {
_events.tryEmit("消息撤回失败,请检查网络后重试")
Log.w(TAG, "revokeMessage failed messageId=$messageId", it)
}
}
}
fun sendImage(uri: Uri) = sendAttachment(uri, AttachmentKind.IMAGE)
fun sendImageBytes(fileName: String, bytes: ByteArray, width: Int? = null, height: Int? = null) {
@ -433,6 +491,10 @@ class ChatViewModel : ViewModel() {
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
.onSuccess {
cache.markConversationRead(targetId, chatType)
AppDependencies.notifyConversationChanged()
}
}
}
}
@ -444,6 +506,10 @@ class ChatViewModel : ViewModel() {
requestScrollToBottom()
}
private fun ImMessage.textContent(): String {
return if (msgType.uppercase() == "TEXT") content else previewText()
}
private fun updateConversationPreview(lastMsgContent: String, lastMsgType: String, lastMsgTime: Long = System.currentTimeMillis()) {
cache.upsertConversation(
ConversationData(
@ -484,6 +550,7 @@ class ChatViewModel : ViewModel() {
status = incoming.status.ifBlank { existing.status },
mentionedUserIds = incoming.mentionedUserIds ?: existing.mentionedUserIds,
groupReadCount = incoming.groupReadCount ?: existing.groupReadCount,
revoked = incoming.revoked ?: existing.revoked,
createdAt = existing.createdAt,
)
}

查看文件

@ -83,6 +83,8 @@ class ContactViewModel(
private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) = handleNotification(message)
override fun onGroupMessage(message: ImMessage) = handleNotification(message)
override fun onRead(message: ImMessage) = handleNotification(message)
override fun onRevoke(message: ImMessage) = handleNotification(message)
}
init {

查看文件

@ -49,6 +49,22 @@ class ConversationViewModel : ViewModel() {
)
refresh()
}
override fun onRead(message: ImMessage) {
Log.d(
TAG,
"incoming read refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
)
refresh()
}
override fun onRevoke(message: ImMessage) {
Log.d(
TAG,
"incoming revoke refresh request id=${message.id} from=${message.fromId} to=${message.toId} msgType=${message.msgType}",
)
refresh()
}
}
init {

查看文件

@ -313,6 +313,7 @@ fun GroupSettingsScreen(
) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val groupMembers by viewModel.groupMembers.collectAsStateWithLifecycle()
val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle()
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
var showEditDialog by remember { mutableStateOf(false) }
@ -320,14 +321,15 @@ fun GroupSettingsScreen(
var showRemoveMemberDialog by remember { mutableStateOf(false) }
var editName by remember { mutableStateOf("") }
var editAnnouncement by remember { mutableStateOf("") }
val memberProfiles = remember(group, members) {
val memberProfiles = remember(group, members, groupMembers) {
val sourceMembers = if (groupMembers.isNotEmpty()) groupMembers else members
val ids = group?.memberIds
?.split(",")
?.map { it.trim() }
?.filter { it.isNotBlank() }
.orEmpty()
ids.mapNotNull { id ->
members.firstOrNull { it.userId == id } ?: UserData(id, "")
sourceMembers.firstOrNull { it.userId == id } ?: UserData(id, "")
}
}
val memberNameById = remember(members) {

查看文件

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.UserProfile
import com.xuqm.sdk.sample.data.api.UserData
import com.xuqm.sdk.sample.data.repo.AuthRepository
import com.xuqm.sdk.sample.di.AppDependencies
@ -22,6 +23,9 @@ class GroupViewModel : ViewModel() {
private val _members = MutableStateFlow<List<UserData>>(emptyList())
val members: StateFlow<List<UserData>> = _members
private val _groupMembers = MutableStateFlow<List<UserData>>(emptyList())
val groupMembers: StateFlow<List<UserData>> = _groupMembers
private val _publicGroups = MutableStateFlow<List<ImGroup>>(emptyList())
val publicGroups: StateFlow<List<ImGroup>> = _publicGroups
@ -58,7 +62,10 @@ class GroupViewModel : ViewModel() {
fun loadGroupInfo(groupId: String) {
viewModelScope.launch {
runCatching { ImSDK.getGroupInfo(groupId) }
.onSuccess { _currentGroup.value = it }
.onSuccess {
_currentGroup.value = it
loadGroupMembersInternal(groupId)
}
}
}
@ -75,6 +82,7 @@ class GroupViewModel : ViewModel() {
try {
loadGroupsInternal()
loadMembersInternal()
_currentGroup.value?.let { loadGroupMembersInternal(it.id) }
loadPublicGroupsInternal(_publicGroupQuery.value)
} finally {
_isRefreshing.value = false
@ -177,6 +185,13 @@ class GroupViewModel : ViewModel() {
}
}
private suspend fun loadGroupMembersInternal(groupId: String) {
runCatching { ImSDK.listGroupMembers(groupId) }
.onSuccess { profiles ->
_groupMembers.value = profiles.map { it.toUserData() }
}
}
private suspend fun loadGroupsInternal() {
runCatching { ImSDK.listGroups() }
.onSuccess { _groups.value = it }
@ -186,4 +201,8 @@ class GroupViewModel : ViewModel() {
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
.onSuccess { _publicGroups.value = it }
}
private fun UserProfile.toUserData(): UserData {
return UserData(userId = userId, nickname = nickname, avatar = avatar)
}
}

查看文件

@ -4,12 +4,15 @@ import android.content.Context
import android.net.Uri
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import java.io.File
data class FileUploadResult(
val url: String,
@ -90,4 +93,31 @@ object FileSDK {
"File upload failed"
}
}
suspend fun downloadToFile(
downloadUrl: String,
targetFile: File,
onProgress: (Int) -> Unit = {},
): File = withContext(Dispatchers.IO) {
FileTransfer.downloadToFile(downloadUrl, targetFile, onProgress)
targetFile
}
suspend fun downloadToAppFiles(
context: Context,
downloadUrl: String,
fileName: String? = null,
directoryName: String? = null,
onProgress: (Int) -> Unit = {},
): File = withContext(Dispatchers.IO) {
val dir = if (directoryName.isNullOrBlank()) {
context.getExternalFilesDir(null) ?: context.filesDir
} else {
File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() }
}
val resolvedName = fileName?.takeIf { it.isNotBlank() } ?: downloadUrl.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "download.bin"
val target = File(dir, resolvedName)
FileTransfer.downloadToFile(downloadUrl, target, onProgress)
target
}
}

查看文件

@ -203,6 +203,13 @@ class ImClient(
append(" status=").append(msg.status)
},
)
if (msg.status.uppercase() == "READ") {
listeners.forEach { it.onRead(msg) }
}
if (msg.status.uppercase() == "REVOKED" || msg.msgType.uppercase() == "REVOKED") {
listeners.forEach { it.onRevoke(msg) }
return
}
if (msg.chatType.uppercase() == "GROUP") {
listeners.forEach { it.onGroupMessage(msg) }
} else {

查看文件

@ -6,6 +6,7 @@ import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.im.api.AddMemberRequest
import com.xuqm.sdk.im.api.CreateGroupRequest
import com.xuqm.sdk.im.api.ImApi
import com.xuqm.sdk.im.model.EditMessageRequest
import com.xuqm.sdk.im.api.MuteGroupMemberRequest
import com.xuqm.sdk.im.api.SetMutedRequest
import com.xuqm.sdk.im.api.SetPinnedRequest
@ -17,6 +18,8 @@ import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.PageResult
import com.xuqm.sdk.im.model.UserProfile
import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.file.FileUploadResult
@ -140,6 +143,12 @@ object ImSDK {
return sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
}
suspend fun editMessage(messageId: String, content: String): ImMessage =
withContext(Dispatchers.IO) { api.editMessage(messageId, XuqmSDK.appId, EditMessageRequest(content)).data ?: throw IllegalStateException("edit message failed") }
suspend fun revokeMessage(messageId: String): ImMessage =
withContext(Dispatchers.IO) { api.revokeMessage(messageId, XuqmSDK.appId).data ?: throw IllegalStateException("revoke message failed") }
fun sendImageMessage(
toId: String,
chatType: String,
@ -433,6 +442,48 @@ object ImSDK {
).data ?: emptyList()
}
suspend fun locateHistoryPage(
toId: String,
messageId: String,
pageSize: Int = 20,
maxPages: Int = 20,
): List<ImMessage>? = locatePage(
maxPages = maxPages,
loadPage = { page -> fetchHistory(toId, page, pageSize) },
messageId = messageId,
pageSize = pageSize,
)
suspend fun locateGroupHistoryPage(
groupId: String,
messageId: String,
pageSize: Int = 20,
maxPages: Int = 20,
): List<ImMessage>? = locatePage(
maxPages = maxPages,
loadPage = { page -> fetchGroupHistory(groupId, page, pageSize) },
messageId = messageId,
pageSize = pageSize,
)
private suspend fun locatePage(
maxPages: Int,
loadPage: suspend (Int) -> List<ImMessage>,
messageId: String,
pageSize: Int,
): List<ImMessage>? {
repeat(maxPages.coerceAtLeast(1)) { page ->
val messages = loadPage(page)
if (messages.any { it.id == messageId }) {
return messages
}
if (messages.size < pageSize) {
return null
}
}
return null
}
suspend fun listGroups(): List<ImGroup> =
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
@ -444,9 +495,43 @@ object ImSDK {
suspend fun getGroupInfo(groupId: String): ImGroup? =
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
suspend fun listGroupMembers(groupId: String): List<UserProfile> =
withContext(Dispatchers.IO) { api.listGroupMembers(groupId, XuqmSDK.appId).data ?: emptyList() }
suspend fun searchGroupMembers(groupId: String, keyword: String, size: Int = 20): List<UserProfile> =
withContext(Dispatchers.IO) { api.searchGroupMembers(groupId, XuqmSDK.appId, keyword, size).data ?: emptyList() }
suspend fun listPublicGroups(keyword: String? = null): List<ImGroup> =
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() }
suspend fun searchUsers(keyword: String, size: Int = 20): List<UserProfile> =
withContext(Dispatchers.IO) { api.searchUsers(XuqmSDK.appId, keyword, size).data ?: emptyList() }
suspend fun searchGroups(keyword: String, size: Int = 20): List<ImGroup> =
withContext(Dispatchers.IO) { api.searchGroups(XuqmSDK.appId, keyword, size).data ?: emptyList() }
suspend fun searchMessages(
keyword: String? = null,
chatType: String? = null,
msgType: String? = null,
startTime: LocalDateTime? = null,
endTime: LocalDateTime? = null,
page: Int = 0,
size: Int = 20,
): PageResult<ImMessage> =
withContext(Dispatchers.IO) {
api.searchMessages(
XuqmSDK.appId,
keyword,
chatType,
msgType,
startTime?.toString(),
endTime?.toString(),
page,
size,
).data ?: PageResult()
}
suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) =
withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) }

查看文件

@ -2,10 +2,12 @@ package com.xuqm.sdk.im.api
import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.BlacklistEntry
import com.xuqm.sdk.im.model.EditMessageRequest
import com.xuqm.sdk.im.model.FriendRequest
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.GroupJoinRequest
import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.model.PageResult
import com.xuqm.sdk.im.model.UserProfile
import retrofit2.http.Body
import retrofit2.http.DELETE
@ -83,6 +85,32 @@ interface ImApi {
@Query("keyword") keyword: String? = null,
): ApiResponse<List<ImGroup>>
@GET("api/im/admin/users/search")
suspend fun searchUsers(
@Query("appId") appId: String,
@Query("keyword") keyword: String,
@Query("size") size: Int = 20,
): ApiResponse<List<UserProfile>>
@GET("api/im/admin/groups/search")
suspend fun searchGroups(
@Query("appId") appId: String,
@Query("keyword") keyword: String,
@Query("size") size: Int = 20,
): ApiResponse<List<ImGroup>>
@GET("api/im/admin/messages/search")
suspend fun searchMessages(
@Query("appId") appId: String,
@Query("keyword") keyword: String? = null,
@Query("chatType") chatType: String? = null,
@Query("msgType") msgType: String? = null,
@Query("startTime") startTime: String? = null,
@Query("endTime") endTime: String? = null,
@Query("page") page: Int = 0,
@Query("size") size: Int = 20,
): ApiResponse<PageResult<ImMessage>>
@POST("api/im/groups")
suspend fun createGroup(
@Query("appId") appId: String,
@ -92,6 +120,20 @@ interface ImApi {
@GET("api/im/groups/{groupId}")
suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse<ImGroup>
@GET("api/im/groups/{groupId}/members")
suspend fun listGroupMembers(
@Path("groupId") groupId: String,
@Query("appId") appId: String,
): ApiResponse<List<UserProfile>>
@GET("api/im/groups/{groupId}/members/search")
suspend fun searchGroupMembers(
@Path("groupId") groupId: String,
@Query("appId") appId: String,
@Query("keyword") keyword: String,
@Query("size") size: Int = 20,
): ApiResponse<List<UserProfile>>
@PUT("api/im/groups/{groupId}")
suspend fun updateGroupInfo(
@Path("groupId") groupId: String,
@ -249,6 +291,19 @@ interface ImApi {
@Query("chatType") chatType: String,
): ApiResponse<Unit>
@PUT("api/im/messages/{messageId}")
suspend fun editMessage(
@Path("messageId") messageId: String,
@Query("appId") appId: String,
@Body request: EditMessageRequest,
): ApiResponse<ImMessage>
@POST("api/im/messages/{messageId}/revoke")
suspend fun revokeMessage(
@Path("messageId") messageId: String,
@Query("appId") appId: String,
): ApiResponse<ImMessage>
@PUT("api/im/conversations/{targetId}/draft")
suspend fun setDraft(
@Path("targetId") targetId: String,

查看文件

@ -7,5 +7,7 @@ interface ImEventListener {
fun onDisconnected(reason: String?) {}
fun onMessage(message: ImMessage) {}
fun onGroupMessage(message: ImMessage) {}
fun onRead(message: ImMessage) {}
fun onRevoke(message: ImMessage) {}
fun onError(error: String) {}
}

查看文件

@ -2,6 +2,22 @@ package com.xuqm.sdk.im.model
import com.google.gson.annotations.SerializedName
data class PageResult<T>(
val content: List<T> = emptyList(),
val totalElements: Long = 0,
val totalPages: Int = 0,
val size: Int = 0,
val number: Int = 0,
val numberOfElements: Int = 0,
val first: Boolean = false,
val last: Boolean = false,
val empty: Boolean = true,
)
data class EditMessageRequest(
val content: String,
)
data class ImMessage(
val id: String,
val appId: String,
@ -14,7 +30,9 @@ data class ImMessage(
val status: String,
val mentionedUserIds: String? = null,
val groupReadCount: Int? = null,
val revoked: Boolean? = null,
val createdAt: Long,
val editedAt: Long? = null,
)
data class ConversationData(

查看文件

@ -0,0 +1,199 @@
/**
* XuqmGroup Update Service — Android Gradle Release Task
*
* Copy this file to your app module directory and apply it:
* apply(from = "xuqm_release.gradle.kts")
*
* Then run:
* ./gradlew xuqmRelease
*
* Config: xuqm.properties in the project root (or module root)
* ---
* xuqm.serverUrl=https://update.dev.xuqinmin.com
* xuqm.appId=your-app-id
* xuqm.apiToken=your-api-token
* ---
*
* The task:
* 1. Reads versionName / versionCode from the android extension
* 2. Checks the latest version on the update server
* 3. Aborts if the local versionCode is not greater than the server's
* 4. Assembles the release APK (assembleRelease)
* 5. Uploads the APK + metadata to the update service as a DRAFT
* 6. Optionally triggers server-side store submission
*/
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.util.Properties
import java.util.UUID
// ── Config loading ────────────────────────────────────────────────────────
fun loadXuqmConfig(projectDir: File): Properties {
val props = Properties()
listOf(
File(projectDir, "xuqm.properties"),
File(projectDir.parentFile, "xuqm.properties"),
).firstOrNull { it.exists() }?.inputStream()?.use(props::load)
?: throw GradleException("xuqm.properties not found in module or project root")
return props
}
// ── HTTP helper ───────────────────────────────────────────────────────────
fun httpGet(url: String, token: String): String {
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer $token")
.GET().build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
/**
* Multipart POST — minimal implementation without external dependencies.
* Returns response body string.
*/
fun httpMultipartPost(url: String, token: String, parts: Map<String, Any>): String {
val boundary = UUID.randomUUID().toString()
val baos = java.io.ByteArrayOutputStream()
fun writeln(s: String) = baos.write(("$s\r\n").toByteArray())
for ((name, value) in parts) {
writeln("--$boundary")
when (value) {
is File -> {
writeln("Content-Disposition: form-data; name=\"$name\"; filename=\"${value.name}\"")
writeln("Content-Type: application/octet-stream")
writeln("")
baos.write(value.readBytes())
writeln("")
}
else -> {
writeln("Content-Disposition: form-data; name=\"$name\"")
writeln("")
writeln(value.toString())
}
}
}
writeln("--$boundary--")
val body = baos.toByteArray()
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer $token")
.header("Content-Type", "multipart/form-data; boundary=$boundary")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
fun parseJson(json: String, key: String): String? =
Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1)
fun parseJsonInt(json: String, key: String): Int? =
Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull()
// ── Task registration ─────────────────────────────────────────────────────
tasks.register("xuqmRelease") {
group = "xuqm"
description = "Build release APK and upload to XuqmGroup Update Service"
// Ensure assembleRelease runs first
dependsOn("assembleRelease")
doLast {
val cfg = loadXuqmConfig(projectDir)
val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing")
val appId = cfg.getProperty("xuqm.appId") ?: throw GradleException("xuqm.appId missing")
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
val storeTargets = cfg.getProperty("xuqm.storeTargets", "") // e.g. "HUAWEI,MI,OPPO"
val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean()
val scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "")
val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "")
// ── 1. Read local version ──────────────────────────────────────────
val android = project.extensions.findByName("android")
?: throw GradleException("This task must run in an Android module")
val defaultConfig = android::class.java.getMethod("getDefaultConfig").invoke(android)
val versionName = defaultConfig::class.java.getMethod("getVersionName").invoke(defaultConfig) as? String
?: throw GradleException("versionName not set")
val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int)
?: throw GradleException("versionCode not set")
val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: ""
println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId")
// ── 2. Check server latest ─────────────────────────────────────────
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appId&platform=ANDROID", apiToken)
// Find highest published versionCode
val serverVersionCode = Regex("\"versionCode\"\\s*:\\s*(\\d+)").findAll(listResp)
.mapNotNull { it.groupValues[1].toIntOrNull() }
.maxOrNull() ?: 0
println("[xuqm] Server latest versionCode: $serverVersionCode")
if (versionCode <= serverVersionCode) {
throw GradleException(
"[xuqm] Local versionCode ($versionCode) must be greater than server ($serverVersionCode). " +
"Please bump versionCode in build.gradle.kts before releasing."
)
}
// ── 3. Locate APK ─────────────────────────────────────────────────
val apkDir = File(buildDir, "outputs/apk/release")
val apkFile = apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull()
?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?")
println("[xuqm] APK: ${apkFile.absolutePath}")
// ── 4. Upload to update service ───────────────────────────────────
val parts = mutableMapOf<String, Any>(
"appId" to appId,
"platform" to "ANDROID",
"versionName" to versionName,
"versionCode" to versionCode,
"forceUpdate" to "false",
"autoPublishAfterReview" to autoPublish.toString(),
"apkFile" to apkFile,
)
if (applicationId.isNotBlank()) parts["packageName"] = applicationId
if (storeTargets.isNotBlank()) parts["storeSubmitTargets"] = "[\"${storeTargets.split(",").joinToString("\",\"")}\"]"
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
println("[xuqm] Uploading APK...")
val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
val versionId = parseJson(uploadResp, "id")
?: throw GradleException("[xuqm] Upload failed:\n$uploadResp")
println("[xuqm] Uploaded, version ID: $versionId")
// ── 5. Trigger server-side store submission ────────────────────────
if (storeTargets.isNotBlank()) {
println("[xuqm] Triggering store submission: $storeTargets ...")
val storeBody = """{"storeTypes":[${storeTargets.split(",").joinToString(",") { "\"$it\"" }}]}"""
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/store/app/$versionId/execute-submit"))
.header("Authorization", "Bearer $apiToken")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(storeBody))
.build()
val storeResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})")
}
println("[xuqm] Done. Version is in DRAFT state.")
if (scheduledAt.isNotBlank()) {
println("[xuqm] Will auto-publish at: $scheduledAt")
} else if (autoPublish) {
println("[xuqm] Will auto-publish after all store reviews pass.")
} else {
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
}
}
}

查看文件

@ -5,11 +5,10 @@ import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.file.FileTransfer
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.update.api.UpdateApi
import com.xuqm.sdk.update.model.RnUpdateInfo
import com.xuqm.sdk.update.model.UpdateInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -22,10 +21,6 @@ object UpdateSDK {
private fun normalizeDownloadUrl(rawUrl: String?): String? {
if (rawUrl.isNullOrBlank()) return rawUrl
if (rawUrl.contains("/api/v1/updates/api/v1/rn/files/")) {
return rawUrl.replace("/api/v1/updates/api/v1/rn/files/", "/api/v1/rn/files/")
}
return runCatching {
val uri = Uri.parse(rawUrl)
if (uri.path?.startsWith("/files/apk/") == true) {
@ -53,7 +48,7 @@ object UpdateSDK {
onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) {
val apkFile = File(context.getExternalFilesDir(null), "update.apk")
FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress)
FileSDK.downloadToFile(downloadUrl, apkFile, onProgress)
withContext(Dispatchers.Main) { installApk(context, apkFile) }
}
@ -65,14 +60,4 @@ object UpdateSDK {
}
context.startActivity(intent)
}
suspend fun checkRnUpdate(moduleId: String, currentVersion: String): RnUpdateInfo? =
withContext(Dispatchers.IO) {
XuqmSDK.requireInit()
runCatching {
api.checkRnUpdate(XuqmSDK.appId, moduleId, "ANDROID", currentVersion).data?.let {
it.copy(downloadUrl = normalizeDownloadUrl(it.downloadUrl) ?: it.downloadUrl)
}
}.getOrNull()
}
}

查看文件

@ -1,7 +1,6 @@
package com.xuqm.sdk.update.api
import com.xuqm.sdk.update.model.UpdateInfo
import com.xuqm.sdk.update.model.RnUpdateInfo
import retrofit2.http.GET
import retrofit2.http.Query
@ -14,12 +13,4 @@ interface UpdateApi {
@Query("platform") platform: String,
@Query("currentVersionCode") currentVersionCode: Int,
): ApiResponse<UpdateInfo>
@GET("api/v1/rn/update/check")
suspend fun checkRnUpdate(
@Query("appId") appId: String,
@Query("moduleId") moduleId: String,
@Query("platform") platform: String,
@Query("currentVersion") currentVersion: String,
): ApiResponse<RnUpdateInfo>
}

查看文件

@ -10,12 +10,3 @@ data class UpdateInfo(
val appStoreUrl: String = "",
val marketUrl: String = "",
)
data class RnUpdateInfo(
val needsUpdate: Boolean,
val latestVersion: String = "",
val downloadUrl: String = "",
val md5: String = "",
val minCommonVersion: String = "0.0.0",
val note: String = "",
)