比较提交

..

没有共同的提交。46777173432347e6734b1f96a9b809e143a8620b 和 dcb263edc6a4f984484fa91226c535e01b3b1e88 的历史完全不同。

共有 17 个文件被更改,包括 41 次插入590 次删除

查看文件

@ -34,17 +34,6 @@ 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))
@ -144,10 +133,7 @@ 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") },
)
)
}
@ -188,10 +174,7 @@ 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,7 +44,6 @@ 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
@ -69,10 +68,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
@ -92,7 +91,6 @@ 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()
@ -105,7 +103,6 @@ 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) }
@ -302,23 +299,6 @@ 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,
@ -446,7 +426,6 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
onEdit = viewModel::startEdit,
)
}
if (searchResults.isEmpty()) {
@ -476,7 +455,6 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
onEdit = viewModel::startEdit,
)
}
if (isLoadingMore) {
@ -534,11 +512,8 @@ 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
@ -567,13 +542,7 @@ private fun MessageBubble(
.padding(horizontal = 4.dp)
.combinedClickable(
onClick = {},
onLongClick = {
if (isOwn && message.msgType.uppercase() == "TEXT") {
onEdit(message)
} else {
onReply(message)
}
},
onLongClick = { onReply(message) },
),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
@ -588,27 +557,6 @@ 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)
@ -817,17 +765,6 @@ 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,9 +46,6 @@ 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
@ -74,14 +71,6 @@ 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) {
@ -101,7 +90,6 @@ 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)
@ -112,10 +100,6 @@ class ChatViewModel : ViewModel() {
}
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
.onSuccess {
cache.markConversationRead(targetId, chatType)
AppDependencies.notifyConversationChanged()
}
}
}
@ -186,25 +170,6 @@ 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(
@ -225,7 +190,6 @@ class ChatViewModel : ViewModel() {
_draftText.value = ""
cache.clearDraft(targetId, chatType)
_replyTargetMessage.value = null
_editingMessage.value = null
}
}
@ -253,28 +217,6 @@ 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) {
@ -491,10 +433,6 @@ 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()
}
}
}
}
@ -506,10 +444,6 @@ 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(
@ -550,7 +484,6 @@ 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,8 +83,6 @@ 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,22 +49,6 @@ 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,7 +313,6 @@ 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) }
@ -321,15 +320,14 @@ fun GroupSettingsScreen(
var showRemoveMemberDialog by remember { mutableStateOf(false) }
var editName by remember { mutableStateOf("") }
var editAnnouncement by remember { mutableStateOf("") }
val memberProfiles = remember(group, members, groupMembers) {
val sourceMembers = if (groupMembers.isNotEmpty()) groupMembers else members
val memberProfiles = remember(group, members) {
val ids = group?.memberIds
?.split(",")
?.map { it.trim() }
?.filter { it.isNotBlank() }
.orEmpty()
ids.mapNotNull { id ->
sourceMembers.firstOrNull { it.userId == id } ?: UserData(id, "")
members.firstOrNull { it.userId == id } ?: UserData(id, "")
}
}
val memberNameById = remember(members) {

查看文件

@ -5,7 +5,6 @@ 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
@ -23,9 +22,6 @@ 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
@ -62,10 +58,7 @@ class GroupViewModel : ViewModel() {
fun loadGroupInfo(groupId: String) {
viewModelScope.launch {
runCatching { ImSDK.getGroupInfo(groupId) }
.onSuccess {
_currentGroup.value = it
loadGroupMembersInternal(groupId)
}
.onSuccess { _currentGroup.value = it }
}
}
@ -82,7 +75,6 @@ class GroupViewModel : ViewModel() {
try {
loadGroupsInternal()
loadMembersInternal()
_currentGroup.value?.let { loadGroupMembersInternal(it.id) }
loadPublicGroupsInternal(_publicGroupQuery.value)
} finally {
_isRefreshing.value = false
@ -185,13 +177,6 @@ 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 }
@ -201,8 +186,4 @@ 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,15 +4,12 @@ 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,
@ -93,31 +90,4 @@ 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,13 +203,6 @@ 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,7 +6,6 @@ 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
@ -18,8 +17,6 @@ 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
@ -143,12 +140,6 @@ 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,
@ -442,48 +433,6 @@ 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() }
@ -495,43 +444,9 @@ 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,12 +2,10 @@ 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
@ -85,32 +83,6 @@ 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,
@ -120,20 +92,6 @@ 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,
@ -291,19 +249,6 @@ 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,7 +7,5 @@ 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,22 +2,6 @@ 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,
@ -30,9 +14,7 @@ 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(

查看文件

@ -1,199 +0,0 @@
/**
* 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,10 +5,11 @@ import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.file.FileTransfer
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
@ -21,6 +22,10 @@ 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) {
@ -48,7 +53,7 @@ object UpdateSDK {
onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) {
val apkFile = File(context.getExternalFilesDir(null), "update.apk")
FileSDK.downloadToFile(downloadUrl, apkFile, onProgress)
FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress)
withContext(Dispatchers.Main) { installApk(context, apkFile) }
}
@ -60,4 +65,14 @@ 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,6 +1,7 @@
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
@ -13,4 +14,12 @@ 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,3 +10,12 @@ 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 = "",
)