feat(android): add xuqm_release Gradle task, expand IM SDK with friends/groups/conversations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
18f4c99b71
当前提交
4677717343
@ -34,6 +34,17 @@ class LocalImCache(context: Context) {
|
|||||||
saveConversations(updated)
|
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> =
|
fun loadHistory(targetId: String, chatType: String): List<ImMessage> =
|
||||||
readMessageList(historyKey(targetId, chatType))
|
readMessageList(historyKey(targetId, chatType))
|
||||||
|
|
||||||
@ -133,7 +144,10 @@ class LocalImCache(context: Context) {
|
|||||||
content = obj.optString("content"),
|
content = obj.optString("content"),
|
||||||
status = obj.optString("status"),
|
status = obj.optString("status"),
|
||||||
mentionedUserIds = obj.optString("mentionedUserIds").takeIf { it.isNotBlank() },
|
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"),
|
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("content", message.content)
|
||||||
put("status", message.status)
|
put("status", message.status)
|
||||||
put("mentionedUserIds", message.mentionedUserIds ?: "")
|
put("mentionedUserIds", message.mentionedUserIds ?: "")
|
||||||
|
message.groupReadCount?.let { put("groupReadCount", it) }
|
||||||
|
put("revoked", message.revoked ?: false)
|
||||||
put("createdAt", message.createdAt)
|
put("createdAt", message.createdAt)
|
||||||
|
message.editedAt?.let { put("editedAt", it) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,7 @@ fun ChatScreen(
|
|||||||
val draftText by viewModel.draftText.collectAsStateWithLifecycle()
|
val draftText by viewModel.draftText.collectAsStateWithLifecycle()
|
||||||
val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle()
|
val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle()
|
||||||
val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle()
|
val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle()
|
||||||
|
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
|
||||||
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
|
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
|
||||||
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
||||||
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
||||||
@ -104,6 +105,7 @@ fun ChatScreen(
|
|||||||
var draftValue by remember { mutableStateOf(TextFieldValue(draftText)) }
|
var draftValue by remember { mutableStateOf(TextFieldValue(draftText)) }
|
||||||
var showSearchBar by remember { mutableStateOf(false) }
|
var showSearchBar by remember { mutableStateOf(false) }
|
||||||
val replyTarget = replyTargetMessage
|
val replyTarget = replyTargetMessage
|
||||||
|
val editingTarget = editingMessage
|
||||||
var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) }
|
var pendingCameraAction by remember { mutableStateOf<CameraAction?>(null) }
|
||||||
var pendingCaptureUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingCaptureUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var isRecordingVoice by remember { mutableStateOf(false) }
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@ -427,6 +446,7 @@ fun ChatScreen(
|
|||||||
isOwn = msg.fromId == viewModel.currentUserId,
|
isOwn = msg.fromId == viewModel.currentUserId,
|
||||||
currentUserId = viewModel.currentUserId,
|
currentUserId = viewModel.currentUserId,
|
||||||
onReply = viewModel::startReply,
|
onReply = viewModel::startReply,
|
||||||
|
onEdit = viewModel::startEdit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (searchResults.isEmpty()) {
|
if (searchResults.isEmpty()) {
|
||||||
@ -456,6 +476,7 @@ fun ChatScreen(
|
|||||||
isOwn = msg.fromId == viewModel.currentUserId,
|
isOwn = msg.fromId == viewModel.currentUserId,
|
||||||
currentUserId = viewModel.currentUserId,
|
currentUserId = viewModel.currentUserId,
|
||||||
onReply = viewModel::startReply,
|
onReply = viewModel::startReply,
|
||||||
|
onEdit = viewModel::startEdit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isLoadingMore) {
|
if (isLoadingMore) {
|
||||||
@ -513,6 +534,7 @@ private fun MessageBubble(
|
|||||||
isOwn: Boolean,
|
isOwn: Boolean,
|
||||||
currentUserId: String,
|
currentUserId: String,
|
||||||
onReply: (ImMessage) -> Unit,
|
onReply: (ImMessage) -> Unit,
|
||||||
|
onEdit: (ImMessage) -> Unit,
|
||||||
) {
|
) {
|
||||||
val media = message.mediaContent()
|
val media = message.mediaContent()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -545,7 +567,13 @@ private fun MessageBubble(
|
|||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = {},
|
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)) {
|
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
|||||||
@ -46,6 +46,9 @@ class ChatViewModel : ViewModel() {
|
|||||||
private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null)
|
private val _replyTargetMessage = MutableStateFlow<ImMessage?>(null)
|
||||||
val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage
|
val replyTargetMessage: StateFlow<ImMessage?> = _replyTargetMessage
|
||||||
|
|
||||||
|
private val _editingMessage = MutableStateFlow<ImMessage?>(null)
|
||||||
|
val editingMessage: StateFlow<ImMessage?> = _editingMessage
|
||||||
|
|
||||||
private val _mentionableUsers = MutableStateFlow<List<UserData>>(emptyList())
|
private val _mentionableUsers = MutableStateFlow<List<UserData>>(emptyList())
|
||||||
val mentionableUsers: StateFlow<List<UserData>> = _mentionableUsers
|
val mentionableUsers: StateFlow<List<UserData>> = _mentionableUsers
|
||||||
|
|
||||||
@ -98,6 +101,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
_draftText.value = cache.loadDraft(targetId, chatType)
|
_draftText.value = cache.loadDraft(targetId, chatType)
|
||||||
_mentionableUsers.value = emptyList()
|
_mentionableUsers.value = emptyList()
|
||||||
_replyTargetMessage.value = null
|
_replyTargetMessage.value = null
|
||||||
|
_editingMessage.value = null
|
||||||
ImSDK.addListener(listener)
|
ImSDK.addListener(listener)
|
||||||
if (chatType == "GROUP") {
|
if (chatType == "GROUP") {
|
||||||
ImSDK.subscribeGroup(targetId)
|
ImSDK.subscribeGroup(targetId)
|
||||||
@ -108,6 +112,10 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.markRead(targetId, chatType) }
|
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||||
|
.onSuccess {
|
||||||
|
cache.markConversationRead(targetId, chatType)
|
||||||
|
AppDependencies.notifyConversationChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,6 +186,25 @@ class ChatViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun sendText(content: String) {
|
fun sendText(content: String) {
|
||||||
if (content.isBlank()) return
|
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 replyTarget = _replyTargetMessage.value
|
||||||
val sent = if (replyTarget != null) {
|
val sent = if (replyTarget != null) {
|
||||||
ImSDK.sendQuoteMessage(
|
ImSDK.sendQuoteMessage(
|
||||||
@ -198,6 +225,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
_draftText.value = ""
|
_draftText.value = ""
|
||||||
cache.clearDraft(targetId, chatType)
|
cache.clearDraft(targetId, chatType)
|
||||||
_replyTargetMessage.value = null
|
_replyTargetMessage.value = null
|
||||||
|
_editingMessage.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,6 +253,16 @@ class ChatViewModel : ViewModel() {
|
|||||||
_replyTargetMessage.value = null
|
_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) {
|
fun revokeMessage(messageId: String) {
|
||||||
if (!initialized) return
|
if (!initialized) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -453,6 +491,10 @@ class ChatViewModel : ViewModel() {
|
|||||||
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
|
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.markRead(targetId, chatType) }
|
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||||
|
.onSuccess {
|
||||||
|
cache.markConversationRead(targetId, chatType)
|
||||||
|
AppDependencies.notifyConversationChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -464,6 +506,10 @@ class ChatViewModel : ViewModel() {
|
|||||||
requestScrollToBottom()
|
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()) {
|
private fun updateConversationPreview(lastMsgContent: String, lastMsgType: String, lastMsgTime: Long = System.currentTimeMillis()) {
|
||||||
cache.upsertConversation(
|
cache.upsertConversation(
|
||||||
ConversationData(
|
ConversationData(
|
||||||
@ -504,6 +550,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
status = incoming.status.ifBlank { existing.status },
|
status = incoming.status.ifBlank { existing.status },
|
||||||
mentionedUserIds = incoming.mentionedUserIds ?: existing.mentionedUserIds,
|
mentionedUserIds = incoming.mentionedUserIds ?: existing.mentionedUserIds,
|
||||||
groupReadCount = incoming.groupReadCount ?: existing.groupReadCount,
|
groupReadCount = incoming.groupReadCount ?: existing.groupReadCount,
|
||||||
|
revoked = incoming.revoked ?: existing.revoked,
|
||||||
createdAt = existing.createdAt,
|
createdAt = existing.createdAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -313,6 +313,7 @@ fun GroupSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
|
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
|
||||||
val members by viewModel.members.collectAsStateWithLifecycle()
|
val members by viewModel.members.collectAsStateWithLifecycle()
|
||||||
|
val groupMembers by viewModel.groupMembers.collectAsStateWithLifecycle()
|
||||||
val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle()
|
val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle()
|
||||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||||
var showEditDialog by remember { mutableStateOf(false) }
|
var showEditDialog by remember { mutableStateOf(false) }
|
||||||
@ -320,14 +321,15 @@ fun GroupSettingsScreen(
|
|||||||
var showRemoveMemberDialog by remember { mutableStateOf(false) }
|
var showRemoveMemberDialog by remember { mutableStateOf(false) }
|
||||||
var editName by remember { mutableStateOf("") }
|
var editName by remember { mutableStateOf("") }
|
||||||
var editAnnouncement 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
|
val ids = group?.memberIds
|
||||||
?.split(",")
|
?.split(",")
|
||||||
?.map { it.trim() }
|
?.map { it.trim() }
|
||||||
?.filter { it.isNotBlank() }
|
?.filter { it.isNotBlank() }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
ids.mapNotNull { id ->
|
ids.mapNotNull { id ->
|
||||||
members.firstOrNull { it.userId == id } ?: UserData(id, "")
|
sourceMembers.firstOrNull { it.userId == id } ?: UserData(id, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val memberNameById = remember(members) {
|
val memberNameById = remember(members) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.GroupJoinRequest
|
import com.xuqm.sdk.im.model.GroupJoinRequest
|
||||||
import com.xuqm.sdk.im.model.ImGroup
|
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.api.UserData
|
||||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||||
import com.xuqm.sdk.sample.di.AppDependencies
|
import com.xuqm.sdk.sample.di.AppDependencies
|
||||||
@ -22,6 +23,9 @@ class GroupViewModel : ViewModel() {
|
|||||||
private val _members = MutableStateFlow<List<UserData>>(emptyList())
|
private val _members = MutableStateFlow<List<UserData>>(emptyList())
|
||||||
val members: StateFlow<List<UserData>> = _members
|
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())
|
private val _publicGroups = MutableStateFlow<List<ImGroup>>(emptyList())
|
||||||
val publicGroups: StateFlow<List<ImGroup>> = _publicGroups
|
val publicGroups: StateFlow<List<ImGroup>> = _publicGroups
|
||||||
|
|
||||||
@ -58,7 +62,10 @@ class GroupViewModel : ViewModel() {
|
|||||||
fun loadGroupInfo(groupId: String) {
|
fun loadGroupInfo(groupId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.getGroupInfo(groupId) }
|
runCatching { ImSDK.getGroupInfo(groupId) }
|
||||||
.onSuccess { _currentGroup.value = it }
|
.onSuccess {
|
||||||
|
_currentGroup.value = it
|
||||||
|
loadGroupMembersInternal(groupId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +82,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
try {
|
try {
|
||||||
loadGroupsInternal()
|
loadGroupsInternal()
|
||||||
loadMembersInternal()
|
loadMembersInternal()
|
||||||
|
_currentGroup.value?.let { loadGroupMembersInternal(it.id) }
|
||||||
loadPublicGroupsInternal(_publicGroupQuery.value)
|
loadPublicGroupsInternal(_publicGroupQuery.value)
|
||||||
} finally {
|
} finally {
|
||||||
_isRefreshing.value = false
|
_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() {
|
private suspend fun loadGroupsInternal() {
|
||||||
runCatching { ImSDK.listGroups() }
|
runCatching { ImSDK.listGroups() }
|
||||||
.onSuccess { _groups.value = it }
|
.onSuccess { _groups.value = it }
|
||||||
@ -186,4 +201,8 @@ class GroupViewModel : ViewModel() {
|
|||||||
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) }
|
||||||
.onSuccess { _publicGroups.value = it }
|
.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? =
|
suspend fun getGroupInfo(groupId: String): ImGroup? =
|
||||||
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
|
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> =
|
suspend fun listPublicGroups(keyword: String? = null): List<ImGroup> =
|
||||||
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() }
|
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() }
|
||||||
|
|
||||||
|
|||||||
@ -120,6 +120,20 @@ interface ImApi {
|
|||||||
@GET("api/im/groups/{groupId}")
|
@GET("api/im/groups/{groupId}")
|
||||||
suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse<ImGroup>
|
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}")
|
@PUT("api/im/groups/{groupId}")
|
||||||
suspend fun updateGroupInfo(
|
suspend fun updateGroupInfo(
|
||||||
@Path("groupId") groupId: String,
|
@Path("groupId") groupId: String,
|
||||||
|
|||||||
@ -30,7 +30,9 @@ data class ImMessage(
|
|||||||
val status: String,
|
val status: String,
|
||||||
val mentionedUserIds: String? = null,
|
val mentionedUserIds: String? = null,
|
||||||
val groupReadCount: Int? = null,
|
val groupReadCount: Int? = null,
|
||||||
|
val revoked: Boolean? = null,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
|
val editedAt: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ConversationData(
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户