比较提交
4 次代码提交
dcb263edc6
...
4677717343
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
4677717343 | ||
|
|
18f4c99b71 | ||
|
|
17168dcf4e | ||
|
|
0425c988ae |
@ -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) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,6 +44,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@ -68,10 +69,10 @@ import com.xuqm.sdk.ui.SearchBarField
|
|||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import com.xuqm.sdk.file.FileSDK
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
|
|
||||||
@ -91,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()
|
||||||
@ -103,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) }
|
||||||
@ -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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@ -426,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()) {
|
||||||
@ -455,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) {
|
||||||
@ -512,8 +534,11 @@ 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 scope = rememberCoroutineScope()
|
||||||
val arrangement = if (isOwn) Arrangement.End else Arrangement.Start
|
val arrangement = if (isOwn) Arrangement.End else Arrangement.Start
|
||||||
val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer
|
val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer
|
||||||
else MaterialTheme.colorScheme.surfaceVariant
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
@ -542,9 +567,15 @@ 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)) {
|
||||||
when (message.msgType.uppercase()) {
|
when (message.msgType.uppercase()) {
|
||||||
"IMAGE" -> ImageBubble(media)
|
"IMAGE" -> ImageBubble(media)
|
||||||
@ -557,6 +588,27 @@ private fun MessageBubble(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
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()
|
val mentionedUserIds = message.mentionedUserIds.orEmpty()
|
||||||
if (mentionedUserIds.isNotBlank() &&
|
if (mentionedUserIds.isNotBlank() &&
|
||||||
mentionedUserIds.split(",").map { it.trim() }.contains(currentUserId)
|
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"
|
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? {
|
private fun mentionQueryAtCursor(value: TextFieldValue): String? {
|
||||||
if (!value.selection.collapsed) return null
|
if (!value.selection.collapsed) return null
|
||||||
val cursor = value.selection.end.coerceIn(0, value.text.length)
|
val cursor = value.selection.end.coerceIn(0, value.text.length)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
@ -71,6 +74,14 @@ class ChatViewModel : ViewModel() {
|
|||||||
override fun onGroupMessage(message: ImMessage) {
|
override fun onGroupMessage(message: ImMessage) {
|
||||||
handleIncomingMessage(message)
|
handleIncomingMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRead(message: ImMessage) {
|
||||||
|
handleIncomingMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRevoke(message: ImMessage) {
|
||||||
|
handleIncomingMessage(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(targetId: String, chatType: String) {
|
fun init(targetId: String, chatType: String) {
|
||||||
@ -90,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)
|
||||||
@ -100,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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,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(
|
||||||
@ -190,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,6 +253,28 @@ 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) {
|
||||||
|
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 sendImage(uri: Uri) = sendAttachment(uri, AttachmentKind.IMAGE)
|
||||||
|
|
||||||
fun sendImageBytes(fileName: String, bytes: ByteArray, width: Int? = null, height: Int? = null) {
|
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) {
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -444,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(
|
||||||
@ -484,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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,6 +83,8 @@ class ContactViewModel(
|
|||||||
private val listener = object : ImEventListener {
|
private val listener = object : ImEventListener {
|
||||||
override fun onMessage(message: ImMessage) = handleNotification(message)
|
override fun onMessage(message: ImMessage) = handleNotification(message)
|
||||||
override fun onGroupMessage(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 {
|
init {
|
||||||
|
|||||||
@ -49,6 +49,22 @@ class ConversationViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
refresh()
|
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 {
|
init {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,15 @@ import android.content.Context
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.xuqm.sdk.core.ServiceEndpointRegistry
|
import com.xuqm.sdk.core.ServiceEndpointRegistry
|
||||||
import com.xuqm.sdk.network.ApiClient
|
import com.xuqm.sdk.network.ApiClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import retrofit2.http.Multipart
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Part
|
import retrofit2.http.Part
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class FileUploadResult(
|
data class FileUploadResult(
|
||||||
val url: String,
|
val url: String,
|
||||||
@ -90,4 +93,31 @@ object FileSDK {
|
|||||||
"File upload failed"
|
"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)
|
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") {
|
if (msg.chatType.uppercase() == "GROUP") {
|
||||||
listeners.forEach { it.onGroupMessage(msg) }
|
listeners.forEach { it.onGroupMessage(msg) }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import com.xuqm.sdk.core.ServiceEndpointRegistry
|
|||||||
import com.xuqm.sdk.im.api.AddMemberRequest
|
import com.xuqm.sdk.im.api.AddMemberRequest
|
||||||
import com.xuqm.sdk.im.api.CreateGroupRequest
|
import com.xuqm.sdk.im.api.CreateGroupRequest
|
||||||
import com.xuqm.sdk.im.api.ImApi
|
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.MuteGroupMemberRequest
|
||||||
import com.xuqm.sdk.im.api.SetMutedRequest
|
import com.xuqm.sdk.im.api.SetMutedRequest
|
||||||
import com.xuqm.sdk.im.api.SetPinnedRequest
|
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.GroupJoinRequest
|
||||||
import com.xuqm.sdk.im.model.ImGroup
|
import com.xuqm.sdk.im.model.ImGroup
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
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.BlacklistEntry
|
||||||
import com.xuqm.sdk.im.model.FriendRequest
|
import com.xuqm.sdk.im.model.FriendRequest
|
||||||
import com.xuqm.sdk.file.FileUploadResult
|
import com.xuqm.sdk.file.FileUploadResult
|
||||||
@ -140,6 +143,12 @@ object ImSDK {
|
|||||||
return sendMessage(toId, chatType, "TEXT", content, mentionedUserIds)
|
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(
|
fun sendImageMessage(
|
||||||
toId: String,
|
toId: String,
|
||||||
chatType: String,
|
chatType: String,
|
||||||
@ -433,6 +442,48 @@ object ImSDK {
|
|||||||
).data ?: emptyList()
|
).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> =
|
suspend fun listGroups(): List<ImGroup> =
|
||||||
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
|
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
|
||||||
|
|
||||||
@ -444,9 +495,43 @@ 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() }
|
||||||
|
|
||||||
|
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) =
|
suspend fun updateGroupInfo(groupId: String, name: String? = null, announcement: String? = null) =
|
||||||
withContext(Dispatchers.IO) { api.updateGroupInfo(groupId, UpdateGroupRequest(name, announcement)) }
|
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.ConversationData
|
||||||
import com.xuqm.sdk.im.model.BlacklistEntry
|
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.FriendRequest
|
||||||
import com.xuqm.sdk.im.model.ImGroup
|
import com.xuqm.sdk.im.model.ImGroup
|
||||||
import com.xuqm.sdk.im.model.GroupJoinRequest
|
import com.xuqm.sdk.im.model.GroupJoinRequest
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
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.UserProfile
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
@ -83,6 +85,32 @@ interface ImApi {
|
|||||||
@Query("keyword") keyword: String? = null,
|
@Query("keyword") keyword: String? = null,
|
||||||
): ApiResponse<List<ImGroup>>
|
): 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")
|
@POST("api/im/groups")
|
||||||
suspend fun createGroup(
|
suspend fun createGroup(
|
||||||
@Query("appId") appId: String,
|
@Query("appId") appId: String,
|
||||||
@ -92,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,
|
||||||
@ -249,6 +291,19 @@ interface ImApi {
|
|||||||
@Query("chatType") chatType: String,
|
@Query("chatType") chatType: String,
|
||||||
): ApiResponse<Unit>
|
): 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")
|
@PUT("api/im/conversations/{targetId}/draft")
|
||||||
suspend fun setDraft(
|
suspend fun setDraft(
|
||||||
@Path("targetId") targetId: String,
|
@Path("targetId") targetId: String,
|
||||||
|
|||||||
@ -7,5 +7,7 @@ interface ImEventListener {
|
|||||||
fun onDisconnected(reason: String?) {}
|
fun onDisconnected(reason: String?) {}
|
||||||
fun onMessage(message: ImMessage) {}
|
fun onMessage(message: ImMessage) {}
|
||||||
fun onGroupMessage(message: ImMessage) {}
|
fun onGroupMessage(message: ImMessage) {}
|
||||||
|
fun onRead(message: ImMessage) {}
|
||||||
|
fun onRevoke(message: ImMessage) {}
|
||||||
fun onError(error: String) {}
|
fun onError(error: String) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,22 @@ package com.xuqm.sdk.im.model
|
|||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
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(
|
data class ImMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
val appId: String,
|
val appId: String,
|
||||||
@ -14,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,11 +5,10 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.xuqm.sdk.XuqmSDK
|
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.core.ServiceEndpointRegistry
|
||||||
import com.xuqm.sdk.network.ApiClient
|
import com.xuqm.sdk.network.ApiClient
|
||||||
import com.xuqm.sdk.update.api.UpdateApi
|
import com.xuqm.sdk.update.api.UpdateApi
|
||||||
import com.xuqm.sdk.update.model.RnUpdateInfo
|
|
||||||
import com.xuqm.sdk.update.model.UpdateInfo
|
import com.xuqm.sdk.update.model.UpdateInfo
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -22,10 +21,6 @@ object UpdateSDK {
|
|||||||
private fun normalizeDownloadUrl(rawUrl: String?): String? {
|
private fun normalizeDownloadUrl(rawUrl: String?): String? {
|
||||||
if (rawUrl.isNullOrBlank()) return rawUrl
|
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 {
|
return runCatching {
|
||||||
val uri = Uri.parse(rawUrl)
|
val uri = Uri.parse(rawUrl)
|
||||||
if (uri.path?.startsWith("/files/apk/") == true) {
|
if (uri.path?.startsWith("/files/apk/") == true) {
|
||||||
@ -53,7 +48,7 @@ object UpdateSDK {
|
|||||||
onProgress: (Int) -> Unit = {},
|
onProgress: (Int) -> Unit = {},
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
val apkFile = File(context.getExternalFilesDir(null), "update.apk")
|
val apkFile = File(context.getExternalFilesDir(null), "update.apk")
|
||||||
FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress)
|
FileSDK.downloadToFile(downloadUrl, apkFile, onProgress)
|
||||||
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,14 +60,4 @@ object UpdateSDK {
|
|||||||
}
|
}
|
||||||
context.startActivity(intent)
|
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
|
package com.xuqm.sdk.update.api
|
||||||
|
|
||||||
import com.xuqm.sdk.update.model.UpdateInfo
|
import com.xuqm.sdk.update.model.UpdateInfo
|
||||||
import com.xuqm.sdk.update.model.RnUpdateInfo
|
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
@ -14,12 +13,4 @@ interface UpdateApi {
|
|||||||
@Query("platform") platform: String,
|
@Query("platform") platform: String,
|
||||||
@Query("currentVersionCode") currentVersionCode: Int,
|
@Query("currentVersionCode") currentVersionCode: Int,
|
||||||
): ApiResponse<UpdateInfo>
|
): 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 appStoreUrl: String = "",
|
||||||
val marketUrl: 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 = "",
|
|
||||||
)
|
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户