比较提交
没有共同的提交。46777173432347e6734b1f96a9b809e143a8620b 和 dcb263edc6a4f984484fa91226c535e01b3b1e88 的历史完全不同。
4677717343
...
dcb263edc6
@ -34,17 +34,6 @@ 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))
|
||||||
|
|
||||||
@ -144,10 +133,7 @@ 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") },
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -188,10 +174,7 @@ 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,7 +44,6 @@ 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
|
||||||
@ -69,10 +68,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
|
||||||
|
|
||||||
@ -92,7 +91,6 @@ 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()
|
||||||
@ -105,7 +103,6 @@ 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) }
|
||||||
@ -302,23 +299,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (editingTarget != null) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "编辑: ${editingTarget.previewText()}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = viewModel::clearEdit) {
|
|
||||||
Text("取消")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@ -446,7 +426,6 @@ 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()) {
|
||||||
@ -476,7 +455,6 @@ 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) {
|
||||||
@ -534,11 +512,8 @@ 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
|
||||||
@ -567,15 +542,9 @@ private fun MessageBubble(
|
|||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {
|
onLongClick = { onReply(message) },
|
||||||
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)
|
||||||
@ -588,27 +557,6 @@ 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)
|
||||||
@ -817,17 +765,6 @@ 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,9 +46,6 @@ 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
|
||||||
|
|
||||||
@ -74,14 +71,6 @@ 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) {
|
||||||
@ -101,7 +90,6 @@ 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)
|
||||||
@ -112,10 +100,6 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.markRead(targetId, chatType) }
|
runCatching { ImSDK.markRead(targetId, chatType) }
|
||||||
.onSuccess {
|
|
||||||
cache.markConversationRead(targetId, chatType)
|
|
||||||
AppDependencies.notifyConversationChanged()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,25 +170,6 @@ 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(
|
||||||
@ -225,7 +190,6 @@ class ChatViewModel : ViewModel() {
|
|||||||
_draftText.value = ""
|
_draftText.value = ""
|
||||||
cache.clearDraft(targetId, chatType)
|
cache.clearDraft(targetId, chatType)
|
||||||
_replyTargetMessage.value = null
|
_replyTargetMessage.value = null
|
||||||
_editingMessage.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,28 +217,6 @@ 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) {
|
||||||
@ -491,10 +433,6 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -506,10 +444,6 @@ 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(
|
||||||
@ -550,7 +484,6 @@ 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,8 +83,6 @@ 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,22 +49,6 @@ 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,7 +313,6 @@ 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) }
|
||||||
@ -321,15 +320,14 @@ 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, groupMembers) {
|
val memberProfiles = remember(group, members) {
|
||||||
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 ->
|
||||||
sourceMembers.firstOrNull { it.userId == id } ?: UserData(id, "")
|
members.firstOrNull { it.userId == id } ?: UserData(id, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val memberNameById = remember(members) {
|
val memberNameById = remember(members) {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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
|
||||||
@ -23,9 +22,6 @@ 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
|
||||||
|
|
||||||
@ -62,10 +58,7 @@ class GroupViewModel : ViewModel() {
|
|||||||
fun loadGroupInfo(groupId: String) {
|
fun loadGroupInfo(groupId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.getGroupInfo(groupId) }
|
runCatching { ImSDK.getGroupInfo(groupId) }
|
||||||
.onSuccess {
|
.onSuccess { _currentGroup.value = it }
|
||||||
_currentGroup.value = it
|
|
||||||
loadGroupMembersInternal(groupId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,7 +75,6 @@ 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
|
||||||
@ -185,13 +177,6 @@ class GroupViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadGroupMembersInternal(groupId: String) {
|
|
||||||
runCatching { ImSDK.listGroupMembers(groupId) }
|
|
||||||
.onSuccess { profiles ->
|
|
||||||
_groupMembers.value = profiles.map { it.toUserData() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadGroupsInternal() {
|
private suspend fun loadGroupsInternal() {
|
||||||
runCatching { ImSDK.listGroups() }
|
runCatching { ImSDK.listGroups() }
|
||||||
.onSuccess { _groups.value = it }
|
.onSuccess { _groups.value = it }
|
||||||
@ -201,8 +186,4 @@ 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,15 +4,12 @@ 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,
|
||||||
@ -93,31 +90,4 @@ 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,13 +203,6 @@ 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,7 +6,6 @@ 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
|
||||||
@ -18,8 +17,6 @@ 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
|
||||||
@ -143,12 +140,6 @@ 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,
|
||||||
@ -442,48 +433,6 @@ 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() }
|
||||||
|
|
||||||
@ -495,43 +444,9 @@ 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,12 +2,10 @@ 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
|
||||||
@ -85,32 +83,6 @@ 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,
|
||||||
@ -120,20 +92,6 @@ 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,
|
||||||
@ -291,19 +249,6 @@ 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,7 +7,5 @@ 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,22 +2,6 @@ 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,
|
||||||
@ -30,9 +14,7 @@ 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(
|
||||||
|
|||||||
@ -1,199 +0,0 @@
|
|||||||
/**
|
|
||||||
* XuqmGroup Update Service — Android Gradle Release Task
|
|
||||||
*
|
|
||||||
* Copy this file to your app module directory and apply it:
|
|
||||||
* apply(from = "xuqm_release.gradle.kts")
|
|
||||||
*
|
|
||||||
* Then run:
|
|
||||||
* ./gradlew xuqmRelease
|
|
||||||
*
|
|
||||||
* Config: xuqm.properties in the project root (or module root)
|
|
||||||
* ---
|
|
||||||
* xuqm.serverUrl=https://update.dev.xuqinmin.com
|
|
||||||
* xuqm.appId=your-app-id
|
|
||||||
* xuqm.apiToken=your-api-token
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* The task:
|
|
||||||
* 1. Reads versionName / versionCode from the android extension
|
|
||||||
* 2. Checks the latest version on the update server
|
|
||||||
* 3. Aborts if the local versionCode is not greater than the server's
|
|
||||||
* 4. Assembles the release APK (assembleRelease)
|
|
||||||
* 5. Uploads the APK + metadata to the update service as a DRAFT
|
|
||||||
* 6. Optionally triggers server-side store submission
|
|
||||||
*/
|
|
||||||
|
|
||||||
import org.gradle.api.DefaultTask
|
|
||||||
import org.gradle.api.GradleException
|
|
||||||
import org.gradle.api.tasks.TaskAction
|
|
||||||
import java.io.File
|
|
||||||
import java.net.URI
|
|
||||||
import java.net.http.HttpClient
|
|
||||||
import java.net.http.HttpRequest
|
|
||||||
import java.net.http.HttpResponse
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.util.Properties
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
// ── Config loading ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun loadXuqmConfig(projectDir: File): Properties {
|
|
||||||
val props = Properties()
|
|
||||||
listOf(
|
|
||||||
File(projectDir, "xuqm.properties"),
|
|
||||||
File(projectDir.parentFile, "xuqm.properties"),
|
|
||||||
).firstOrNull { it.exists() }?.inputStream()?.use(props::load)
|
|
||||||
?: throw GradleException("xuqm.properties not found in module or project root")
|
|
||||||
return props
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP helper ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun httpGet(url: String, token: String): String {
|
|
||||||
val client = HttpClient.newHttpClient()
|
|
||||||
val req = HttpRequest.newBuilder(URI.create(url))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.GET().build()
|
|
||||||
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multipart POST — minimal implementation without external dependencies.
|
|
||||||
* Returns response body string.
|
|
||||||
*/
|
|
||||||
fun httpMultipartPost(url: String, token: String, parts: Map<String, Any>): String {
|
|
||||||
val boundary = UUID.randomUUID().toString()
|
|
||||||
val baos = java.io.ByteArrayOutputStream()
|
|
||||||
|
|
||||||
fun writeln(s: String) = baos.write(("$s\r\n").toByteArray())
|
|
||||||
|
|
||||||
for ((name, value) in parts) {
|
|
||||||
writeln("--$boundary")
|
|
||||||
when (value) {
|
|
||||||
is File -> {
|
|
||||||
writeln("Content-Disposition: form-data; name=\"$name\"; filename=\"${value.name}\"")
|
|
||||||
writeln("Content-Type: application/octet-stream")
|
|
||||||
writeln("")
|
|
||||||
baos.write(value.readBytes())
|
|
||||||
writeln("")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
writeln("Content-Disposition: form-data; name=\"$name\"")
|
|
||||||
writeln("")
|
|
||||||
writeln(value.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeln("--$boundary--")
|
|
||||||
|
|
||||||
val body = baos.toByteArray()
|
|
||||||
val client = HttpClient.newHttpClient()
|
|
||||||
val req = HttpRequest.newBuilder(URI.create(url))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.header("Content-Type", "multipart/form-data; boundary=$boundary")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
|
||||||
.build()
|
|
||||||
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseJson(json: String, key: String): String? =
|
|
||||||
Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1)
|
|
||||||
fun parseJsonInt(json: String, key: String): Int? =
|
|
||||||
Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull()
|
|
||||||
|
|
||||||
// ── Task registration ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
tasks.register("xuqmRelease") {
|
|
||||||
group = "xuqm"
|
|
||||||
description = "Build release APK and upload to XuqmGroup Update Service"
|
|
||||||
|
|
||||||
// Ensure assembleRelease runs first
|
|
||||||
dependsOn("assembleRelease")
|
|
||||||
|
|
||||||
doLast {
|
|
||||||
val cfg = loadXuqmConfig(projectDir)
|
|
||||||
val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing")
|
|
||||||
val appId = cfg.getProperty("xuqm.appId") ?: throw GradleException("xuqm.appId missing")
|
|
||||||
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
|
|
||||||
val storeTargets = cfg.getProperty("xuqm.storeTargets", "") // e.g. "HUAWEI,MI,OPPO"
|
|
||||||
val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean()
|
|
||||||
val scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "")
|
|
||||||
val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "")
|
|
||||||
|
|
||||||
// ── 1. Read local version ──────────────────────────────────────────
|
|
||||||
val android = project.extensions.findByName("android")
|
|
||||||
?: throw GradleException("This task must run in an Android module")
|
|
||||||
val defaultConfig = android::class.java.getMethod("getDefaultConfig").invoke(android)
|
|
||||||
val versionName = defaultConfig::class.java.getMethod("getVersionName").invoke(defaultConfig) as? String
|
|
||||||
?: throw GradleException("versionName not set")
|
|
||||||
val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int)
|
|
||||||
?: throw GradleException("versionCode not set")
|
|
||||||
val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: ""
|
|
||||||
println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId")
|
|
||||||
|
|
||||||
// ── 2. Check server latest ─────────────────────────────────────────
|
|
||||||
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appId&platform=ANDROID", apiToken)
|
|
||||||
// Find highest published versionCode
|
|
||||||
val serverVersionCode = Regex("\"versionCode\"\\s*:\\s*(\\d+)").findAll(listResp)
|
|
||||||
.mapNotNull { it.groupValues[1].toIntOrNull() }
|
|
||||||
.maxOrNull() ?: 0
|
|
||||||
println("[xuqm] Server latest versionCode: $serverVersionCode")
|
|
||||||
|
|
||||||
if (versionCode <= serverVersionCode) {
|
|
||||||
throw GradleException(
|
|
||||||
"[xuqm] Local versionCode ($versionCode) must be greater than server ($serverVersionCode). " +
|
|
||||||
"Please bump versionCode in build.gradle.kts before releasing."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. Locate APK ─────────────────────────────────────────────────
|
|
||||||
val apkDir = File(buildDir, "outputs/apk/release")
|
|
||||||
val apkFile = apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull()
|
|
||||||
?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?")
|
|
||||||
println("[xuqm] APK: ${apkFile.absolutePath}")
|
|
||||||
|
|
||||||
// ── 4. Upload to update service ───────────────────────────────────
|
|
||||||
val parts = mutableMapOf<String, Any>(
|
|
||||||
"appId" to appId,
|
|
||||||
"platform" to "ANDROID",
|
|
||||||
"versionName" to versionName,
|
|
||||||
"versionCode" to versionCode,
|
|
||||||
"forceUpdate" to "false",
|
|
||||||
"autoPublishAfterReview" to autoPublish.toString(),
|
|
||||||
"apkFile" to apkFile,
|
|
||||||
)
|
|
||||||
if (applicationId.isNotBlank()) parts["packageName"] = applicationId
|
|
||||||
if (storeTargets.isNotBlank()) parts["storeSubmitTargets"] = "[\"${storeTargets.split(",").joinToString("\",\"")}\"]"
|
|
||||||
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
|
|
||||||
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
|
|
||||||
|
|
||||||
println("[xuqm] Uploading APK...")
|
|
||||||
val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
|
|
||||||
val versionId = parseJson(uploadResp, "id")
|
|
||||||
?: throw GradleException("[xuqm] Upload failed:\n$uploadResp")
|
|
||||||
println("[xuqm] Uploaded, version ID: $versionId")
|
|
||||||
|
|
||||||
// ── 5. Trigger server-side store submission ────────────────────────
|
|
||||||
if (storeTargets.isNotBlank()) {
|
|
||||||
println("[xuqm] Triggering store submission: $storeTargets ...")
|
|
||||||
val storeBody = """{"storeTypes":[${storeTargets.split(",").joinToString(",") { "\"$it\"" }}]}"""
|
|
||||||
val client = HttpClient.newHttpClient()
|
|
||||||
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/store/app/$versionId/execute-submit"))
|
|
||||||
.header("Authorization", "Bearer $apiToken")
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(storeBody))
|
|
||||||
.build()
|
|
||||||
val storeResp = client.send(req, HttpResponse.BodyHandlers.ofString())
|
|
||||||
println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})")
|
|
||||||
}
|
|
||||||
|
|
||||||
println("[xuqm] Done. Version is in DRAFT state.")
|
|
||||||
if (scheduledAt.isNotBlank()) {
|
|
||||||
println("[xuqm] Will auto-publish at: $scheduledAt")
|
|
||||||
} else if (autoPublish) {
|
|
||||||
println("[xuqm] Will auto-publish after all store reviews pass.")
|
|
||||||
} else {
|
|
||||||
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,10 +5,11 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import 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.FileSDK
|
import com.xuqm.sdk.file.FileTransfer
|
||||||
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
|
||||||
@ -21,6 +22,10 @@ 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) {
|
||||||
@ -48,7 +53,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")
|
||||||
FileSDK.downloadToFile(downloadUrl, apkFile, onProgress)
|
FileTransfer.downloadToFile(downloadUrl, apkFile, onProgress)
|
||||||
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,4 +65,14 @@ 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,6 +1,7 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -13,4 +14,12 @@ 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,3 +10,12 @@ 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 = "",
|
||||||
|
)
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户