feat(android): add xuqm_release Gradle task, expand IM SDK with friends/groups/conversations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-29 00:36:51 +08:00
父节点 18f4c99b71
当前提交 4677717343
共有 9 个文件被更改,包括 339 次插入5 次删除

查看文件

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

查看文件

@ -92,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()
@ -104,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) }
@ -300,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,
@ -427,6 +446,7 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
onEdit = viewModel::startEdit,
)
}
if (searchResults.isEmpty()) {
@ -456,6 +476,7 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId,
onReply = viewModel::startReply,
onEdit = viewModel::startEdit,
)
}
if (isLoadingMore) {
@ -513,6 +534,7 @@ private fun MessageBubble(
isOwn: Boolean,
currentUserId: String,
onReply: (ImMessage) -> Unit,
onEdit: (ImMessage) -> Unit,
) {
val media = message.mediaContent()
val context = LocalContext.current
@ -545,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)

查看文件

@ -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
@ -98,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)
@ -108,6 +112,10 @@ class ChatViewModel : ViewModel() {
}
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
.onSuccess {
cache.markConversationRead(targetId, chatType)
AppDependencies.notifyConversationChanged()
}
}
}
@ -178,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(
@ -198,6 +225,7 @@ class ChatViewModel : ViewModel() {
_draftText.value = ""
cache.clearDraft(targetId, chatType)
_replyTargetMessage.value = null
_editingMessage.value = null
}
}
@ -225,6 +253,16 @@ 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 {
@ -453,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()
}
}
}
}
@ -464,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(
@ -504,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,
)
}

查看文件

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

查看文件

@ -495,6 +495,12 @@ 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() }

查看文件

@ -120,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,

查看文件

@ -30,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")
}
}
}