From 46777173432347e6734b1f96a9b809e143a8620b Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 29 Apr 2026 00:36:51 +0800 Subject: [PATCH] feat(android): add xuqm_release Gradle task, expand IM SDK with friends/groups/conversations Co-Authored-By: Claude Sonnet 4.6 --- .../sdk/sample/data/local/LocalImCache.kt | 17 ++ .../com/xuqm/sdk/sample/ui/chat/ChatScreen.kt | 32 ++- .../xuqm/sdk/sample/ui/chat/ChatViewModel.kt | 47 +++++ .../xuqm/sdk/sample/ui/group/GroupScreen.kt | 6 +- .../sdk/sample/ui/group/GroupViewModel.kt | 21 +- sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt | 6 + .../main/java/com/xuqm/sdk/im/api/ImApi.kt | 14 ++ .../java/com/xuqm/sdk/im/model/ImMessage.kt | 2 + sdk-update/scripts/xuqm_release.gradle.kts | 199 ++++++++++++++++++ 9 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 sdk-update/scripts/xuqm_release.gradle.kts diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt index 194e81b..63d37d3 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt @@ -34,6 +34,17 @@ class LocalImCache(context: Context) { saveConversations(updated) } + fun markConversationRead(targetId: String, chatType: String) { + val updated = loadConversations().map { conversation -> + if (conversation.targetId == targetId && conversation.chatType == chatType) { + conversation.copy(unreadCount = 0) + } else { + conversation + } + } + saveConversations(updated) + } + fun loadHistory(targetId: String, chatType: String): List = readMessageList(historyKey(targetId, chatType)) @@ -133,7 +144,10 @@ class LocalImCache(context: Context) { content = obj.optString("content"), status = obj.optString("status"), mentionedUserIds = obj.optString("mentionedUserIds").takeIf { it.isNotBlank() }, + groupReadCount = obj.optInt("groupReadCount").takeIf { obj.has("groupReadCount") }, + revoked = obj.optBoolean("revoked").takeIf { obj.has("revoked") }, createdAt = obj.optLong("createdAt"), + editedAt = obj.optLong("editedAt").takeIf { obj.has("editedAt") }, ) ) } @@ -174,7 +188,10 @@ class LocalImCache(context: Context) { put("content", message.content) put("status", message.status) put("mentionedUserIds", message.mentionedUserIds ?: "") + message.groupReadCount?.let { put("groupReadCount", it) } + put("revoked", message.revoked ?: false) put("createdAt", message.createdAt) + message.editedAt?.let { put("editedAt", it) } } ) } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt index d316bff..bea4f2b 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt @@ -92,6 +92,7 @@ fun ChatScreen( val draftText by viewModel.draftText.collectAsStateWithLifecycle() val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle() val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle() + val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle() val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle() val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle() val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle() @@ -104,6 +105,7 @@ fun ChatScreen( var draftValue by remember { mutableStateOf(TextFieldValue(draftText)) } var showSearchBar by remember { mutableStateOf(false) } val replyTarget = replyTargetMessage + val editingTarget = editingMessage var pendingCameraAction by remember { mutableStateOf(null) } var pendingCaptureUri by remember { mutableStateOf(null) } var isRecordingVoice by remember { mutableStateOf(false) } @@ -300,6 +302,23 @@ fun ChatScreen( } } } + if (editingTarget != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "编辑: ${editingTarget.previewText()}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = viewModel::clearEdit) { + Text("取消") + } + } + } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -427,6 +446,7 @@ fun ChatScreen( isOwn = msg.fromId == viewModel.currentUserId, currentUserId = viewModel.currentUserId, onReply = viewModel::startReply, + onEdit = viewModel::startEdit, ) } if (searchResults.isEmpty()) { @@ -456,6 +476,7 @@ fun ChatScreen( isOwn = msg.fromId == viewModel.currentUserId, currentUserId = viewModel.currentUserId, onReply = viewModel::startReply, + onEdit = viewModel::startEdit, ) } if (isLoadingMore) { @@ -513,6 +534,7 @@ private fun MessageBubble( isOwn: Boolean, currentUserId: String, onReply: (ImMessage) -> Unit, + onEdit: (ImMessage) -> Unit, ) { val media = message.mediaContent() val context = LocalContext.current @@ -545,9 +567,15 @@ private fun MessageBubble( .padding(horizontal = 4.dp) .combinedClickable( onClick = {}, - onLongClick = { onReply(message) }, + onLongClick = { + if (isOwn && message.msgType.uppercase() == "TEXT") { + onEdit(message) + } else { + onReply(message) + } + }, ), - ) { + ) { Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { when (message.msgType.uppercase()) { "IMAGE" -> ImageBubble(media) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt index 407fb2e..47c3537 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt @@ -46,6 +46,9 @@ class ChatViewModel : ViewModel() { private val _replyTargetMessage = MutableStateFlow(null) val replyTargetMessage: StateFlow = _replyTargetMessage + private val _editingMessage = MutableStateFlow(null) + val editingMessage: StateFlow = _editingMessage + private val _mentionableUsers = MutableStateFlow>(emptyList()) val mentionableUsers: StateFlow> = _mentionableUsers @@ -98,6 +101,7 @@ class ChatViewModel : ViewModel() { _draftText.value = cache.loadDraft(targetId, chatType) _mentionableUsers.value = emptyList() _replyTargetMessage.value = null + _editingMessage.value = null ImSDK.addListener(listener) if (chatType == "GROUP") { ImSDK.subscribeGroup(targetId) @@ -108,6 +112,10 @@ class ChatViewModel : ViewModel() { } viewModelScope.launch { runCatching { ImSDK.markRead(targetId, chatType) } + .onSuccess { + cache.markConversationRead(targetId, chatType) + AppDependencies.notifyConversationChanged() + } } } @@ -178,6 +186,25 @@ class ChatViewModel : ViewModel() { fun sendText(content: String) { if (content.isBlank()) return + val editingTarget = _editingMessage.value + if (editingTarget != null) { + viewModelScope.launch { + runCatching { ImSDK.editMessage(editingTarget.id, content) } + .onSuccess { updated -> + handleIncomingMessage(updated) + _editingMessage.value = null + _replyTargetMessage.value = null + _draftText.value = "" + cache.clearDraft(targetId, chatType) + } + .onFailure { + _events.tryEmit("消息编辑失败,请检查网络后重试") + Log.w(TAG, "editMessage failed messageId=${editingTarget.id}", it) + } + } + return + } + val replyTarget = _replyTargetMessage.value val sent = if (replyTarget != null) { ImSDK.sendQuoteMessage( @@ -198,6 +225,7 @@ class ChatViewModel : ViewModel() { _draftText.value = "" cache.clearDraft(targetId, chatType) _replyTargetMessage.value = null + _editingMessage.value = null } } @@ -225,6 +253,16 @@ class ChatViewModel : ViewModel() { _replyTargetMessage.value = null } + fun startEdit(message: ImMessage) { + _editingMessage.value = message + _replyTargetMessage.value = null + updateDraft(message.textContent()) + } + + fun clearEdit() { + _editingMessage.value = null + } + fun revokeMessage(messageId: String) { if (!initialized) return viewModelScope.launch { @@ -453,6 +491,10 @@ class ChatViewModel : ViewModel() { if (message.status.uppercase() != "READ" && message.fromId != currentUserId) { viewModelScope.launch { runCatching { ImSDK.markRead(targetId, chatType) } + .onSuccess { + cache.markConversationRead(targetId, chatType) + AppDependencies.notifyConversationChanged() + } } } } @@ -464,6 +506,10 @@ class ChatViewModel : ViewModel() { requestScrollToBottom() } + private fun ImMessage.textContent(): String { + return if (msgType.uppercase() == "TEXT") content else previewText() + } + private fun updateConversationPreview(lastMsgContent: String, lastMsgType: String, lastMsgTime: Long = System.currentTimeMillis()) { cache.upsertConversation( ConversationData( @@ -504,6 +550,7 @@ class ChatViewModel : ViewModel() { status = incoming.status.ifBlank { existing.status }, mentionedUserIds = incoming.mentionedUserIds ?: existing.mentionedUserIds, groupReadCount = incoming.groupReadCount ?: existing.groupReadCount, + revoked = incoming.revoked ?: existing.revoked, createdAt = existing.createdAt, ) } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt index 05557fc..1d2cf9f 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt @@ -313,6 +313,7 @@ fun GroupSettingsScreen( ) { val group by viewModel.currentGroup.collectAsStateWithLifecycle() val members by viewModel.members.collectAsStateWithLifecycle() + val groupMembers by viewModel.groupMembers.collectAsStateWithLifecycle() val joinRequests by viewModel.joinRequests.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() var showEditDialog by remember { mutableStateOf(false) } @@ -320,14 +321,15 @@ fun GroupSettingsScreen( var showRemoveMemberDialog by remember { mutableStateOf(false) } var editName by remember { mutableStateOf("") } var editAnnouncement by remember { mutableStateOf("") } - val memberProfiles = remember(group, members) { + val memberProfiles = remember(group, members, groupMembers) { + val sourceMembers = if (groupMembers.isNotEmpty()) groupMembers else members val ids = group?.memberIds ?.split(",") ?.map { it.trim() } ?.filter { it.isNotBlank() } .orEmpty() ids.mapNotNull { id -> - members.firstOrNull { it.userId == id } ?: UserData(id, "") + sourceMembers.firstOrNull { it.userId == id } ?: UserData(id, "") } } val memberNameById = remember(members) { diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt index 69e7db0..a66659e 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.model.GroupJoinRequest import com.xuqm.sdk.im.model.ImGroup +import com.xuqm.sdk.im.model.UserProfile import com.xuqm.sdk.sample.data.api.UserData import com.xuqm.sdk.sample.data.repo.AuthRepository import com.xuqm.sdk.sample.di.AppDependencies @@ -22,6 +23,9 @@ class GroupViewModel : ViewModel() { private val _members = MutableStateFlow>(emptyList()) val members: StateFlow> = _members + private val _groupMembers = MutableStateFlow>(emptyList()) + val groupMembers: StateFlow> = _groupMembers + private val _publicGroups = MutableStateFlow>(emptyList()) val publicGroups: StateFlow> = _publicGroups @@ -58,7 +62,10 @@ class GroupViewModel : ViewModel() { fun loadGroupInfo(groupId: String) { viewModelScope.launch { runCatching { ImSDK.getGroupInfo(groupId) } - .onSuccess { _currentGroup.value = it } + .onSuccess { + _currentGroup.value = it + loadGroupMembersInternal(groupId) + } } } @@ -75,6 +82,7 @@ class GroupViewModel : ViewModel() { try { loadGroupsInternal() loadMembersInternal() + _currentGroup.value?.let { loadGroupMembersInternal(it.id) } loadPublicGroupsInternal(_publicGroupQuery.value) } finally { _isRefreshing.value = false @@ -177,6 +185,13 @@ class GroupViewModel : ViewModel() { } } + private suspend fun loadGroupMembersInternal(groupId: String) { + runCatching { ImSDK.listGroupMembers(groupId) } + .onSuccess { profiles -> + _groupMembers.value = profiles.map { it.toUserData() } + } + } + private suspend fun loadGroupsInternal() { runCatching { ImSDK.listGroups() } .onSuccess { _groups.value = it } @@ -186,4 +201,8 @@ class GroupViewModel : ViewModel() { runCatching { ImSDK.listPublicGroups(keyword.ifBlank { null }) } .onSuccess { _publicGroups.value = it } } + + private fun UserProfile.toUserData(): UserData { + return UserData(userId = userId, nickname = nickname, avatar = avatar) + } } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt index 1b2288f..d4535b5 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -495,6 +495,12 @@ object ImSDK { suspend fun getGroupInfo(groupId: String): ImGroup? = withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data } + suspend fun listGroupMembers(groupId: String): List = + withContext(Dispatchers.IO) { api.listGroupMembers(groupId, XuqmSDK.appId).data ?: emptyList() } + + suspend fun searchGroupMembers(groupId: String, keyword: String, size: Int = 20): List = + withContext(Dispatchers.IO) { api.searchGroupMembers(groupId, XuqmSDK.appId, keyword, size).data ?: emptyList() } + suspend fun listPublicGroups(keyword: String? = null): List = withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt index f6ea387..5240b48 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt @@ -120,6 +120,20 @@ interface ImApi { @GET("api/im/groups/{groupId}") suspend fun getGroupInfo(@Path("groupId") groupId: String): ApiResponse + @GET("api/im/groups/{groupId}/members") + suspend fun listGroupMembers( + @Path("groupId") groupId: String, + @Query("appId") appId: String, + ): ApiResponse> + + @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> + @PUT("api/im/groups/{groupId}") suspend fun updateGroupInfo( @Path("groupId") groupId: String, diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt index 8d225bd..aabaa60 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/model/ImMessage.kt @@ -30,7 +30,9 @@ data class ImMessage( val status: String, val mentionedUserIds: String? = null, val groupReadCount: Int? = null, + val revoked: Boolean? = null, val createdAt: Long, + val editedAt: Long? = null, ) data class ConversationData( diff --git a/sdk-update/scripts/xuqm_release.gradle.kts b/sdk-update/scripts/xuqm_release.gradle.kts new file mode 100644 index 0000000..d31bb6e --- /dev/null +++ b/sdk-update/scripts/xuqm_release.gradle.kts @@ -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 { + 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( + "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") + } + } +}