diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 9804362e7..632c87c7b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -15,6 +15,8 @@ interface ChatEventCallback { * Очередь JNI подтверждает приём команды, результат операции приходит отдельным CHAT_EVT_*. * JSON snapshots читаются в worker синхронно: тяжёлые getters вызывать на IO. */ object NativeLib { + /** Preview без token; применение с token снимка и JSON-массивом decimal ID для переподписи. Результат — событие74. */ + external fun nativeGroupPolicy(id: Long, group: Long, all: Boolean, token: String?, keep: String): Boolean @Volatile private var coreStopping = false @Volatile private var eventCallback: ChatEventCallback? = null private val lifecycle = Mutex() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt index fc36b6640..dd3eab594 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt @@ -21,6 +21,8 @@ import com.utun.chat.data.* */ @Composable fun GroupPropertiesDialog(channel: Channel, onDismiss: () -> Unit) { + var showPolicy by remember { mutableStateOf(false) } + if (showPolicy) GroupPolicyDialog(channel.id) { showPolicy = false } val repository = ChatApplication.instance.avatars val groups by repository.groups.collectAsState() val edits by repository.edits.collectAsState() @@ -42,6 +44,7 @@ fun GroupPropertiesDialog(channel: Channel, onDismiss: () -> Unit) { title = { Text(stringResource(R.string.group_properties)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { showPolicy = true }) { Text(stringResource(R.string.group_policy_title)) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { GroupAvatar(channel.id, channel.name, size = 64.dp) Column { Text(channel.name, style = MaterialTheme.typography.titleMedium); Text(channel.id) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/GroupPolicyDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/GroupPolicyDialog.kt new file mode 100644 index 000000000..2de75e7e7 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/GroupPolicyDialog.kt @@ -0,0 +1,124 @@ +package com.utun.chat.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.utun.chat.AppEventHandler +import com.utun.chat.R +import com.utun.chat.data.LogManager +import com.utun.chat.data.NativeLib +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import org.json.JSONArray +import org.json.JSONObject +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.atomic.AtomicLong + +private val policySequence = AtomicLong(0) + +/** Подписка создаётся до enqueue; ID исключает ответ предыдущего окна/запроса. */ +private suspend fun policyRequest(group: Long, all: Boolean, token: String?, keep: Set): JSONObject = coroutineScope { + val id = policySequence.incrementAndGet() + val response = async(start = CoroutineStart.UNDISPATCHED) { + withTimeout(15000) { + AppEventHandler.events.first { (type, bytes) -> + type == 74 && bytes != null && bytes.size >= 18 && + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).long == id + }.second!! + } + } + if (!NativeLib.nativeGroupPolicy(id, group, all, token, JSONArray(keep.toList()).toString())) { + response.cancel(); throw IllegalStateException("policy enqueue rejected") + } + val bytes = response.await() + JSONObject(bytes.copyOfRange(16, bytes.size).toString(Charsets.UTF_8)) +} + +/** Ядро строит список тем же алгоритмом, которым применяет допуск. Изменения требуют отдельного Apply. */ +@Composable +fun GroupPolicyDialog(channel: String, onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + var snapshot by remember { mutableStateOf(null) } + var desired by remember { mutableStateOf(false) } + var busy by remember { mutableStateOf(true) } + var selected by remember { mutableStateOf(setOf()) } + var status by remember { mutableStateOf(null) } + val current = snapshot?.optBoolean("all_members") ?: false + val canEdit = snapshot?.optBoolean("can_edit") ?: false + fun request(all: Boolean, apply: Boolean) { + busy = true; status = null + scope.launch { + try { + val result = policyRequest(channel.toLong(), all, if (apply) snapshot?.getString("token") else null, if (apply) selected else emptySet()) + if (!result.optBoolean("ok")) { + status = when (result.optString("error")) { + "conflict" -> R.string.group_policy_conflict + "sync_pending" -> R.string.group_policy_sync + else -> R.string.group_policy_failed + } + if (result.optString("error") == "conflict") { + snapshot = policyRequest(channel.toLong(), all, null, emptySet()); selected = emptySet() + } else snapshot = null + } else { + if (snapshot == null || apply) desired = result.getBoolean("all_members") + snapshot = result; selected = emptySet() + if (apply) status = R.string.group_policy_saved + } + } catch (e: CancellationException) { + if (e !is TimeoutCancellationException) throw e + status = R.string.group_policy_timeout; snapshot = null + } catch (e: Exception) { + LogManager.addLog("ERROR", "GroupPolicy", "Request failed channel=$channel: $e") + status = R.string.group_policy_failed; snapshot = null + } finally { busy = false } + } + } + LaunchedEffect(channel) { request(false, false) } + val affected = if (desired || desired == current) emptyList() else { + val array = snapshot?.optJSONArray("affected") ?: JSONArray() + (0 until array.length()).map { array.getJSONObject(it) } + } + AlertDialog(onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.group_policy_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (all in listOf(false, true)) Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = desired == all, enabled = canEdit && !busy, onClick = { desired = all; request(all, false) }) + Text(stringResource(if (all) R.string.group_policy_all else R.string.group_policy_admins)) + } + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(R.string.group_policy_explanation)) + if (snapshot != null && !canEdit) Text(stringResource(R.string.group_policy_owner_only)) + if (affected.isNotEmpty()) { + Text(stringResource(R.string.group_policy_affected, affected.size)) + LazyColumn(Modifier.heightIn(max = 240.dp)) { + items(affected, key = { it.getString("node") }) { member -> + val node = member.getString("node") + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = node in selected, enabled = !busy && canEdit, onCheckedChange = { + selected = if (it) selected + node else selected - node + }) + Column { + Text(member.optString("name").ifEmpty { node }) + Text(node, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.group_policy_inviter, member.getString("inviter")), style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + status?.let { Text(stringResource(it)) } + if (!busy && snapshot == null) TextButton(onClick = { request(desired, false) }) { Text(stringResource(R.string.group_policy_refresh)) } + } + }, + confirmButton = { TextButton(enabled = !busy && canEdit && snapshot?.optBoolean("ok") == true && desired != current, + onClick = { request(desired, true) }) { Text(stringResource(R.string.group_policy_apply)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } }) +} diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 9b498e416..030c939a9 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -707,4 +707,18 @@ Имя группы устройств Не удалось переименовать группу устройств Свернуть устройства + Кто может приглашать участников + Все участники группы + Только владелец и администраторы + При следующей сверке участники с неподходящей цепочкой приглашений покинут группу. Отметьте тех, кого нужно переподписать ключом группы, чтобы сохранить их участие. + Изменить настройку может только суперадминистратор. + Под ограничение попадают: %1$d + Пригласил: %1$s + Состав группы изменился. Проверьте обновлённый список и подтвердите снова. + Идёт сверка участников. Повторите после её завершения. + Права приглашения сохранены. + Не удалось изменить права приглашения. + Время ожидания истекло. Откройте настройки снова, чтобы проверить результат. + Обновить + Применить diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 1dccb75c1..11cffcf99 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -706,4 +706,18 @@ Device group name Could not rename device group Collapse devices + Who may invite members + All group members + Owner and administrators only + Members whose invitation chain no longer qualifies leave at the next synchronization. Select members to re-sign with the group key and keep them. + Only a superadministrator can change this setting. + Members affected: %1$d + Invited by: %1$s + Membership changed. Review the refreshed list and apply again. + Membership is synchronizing. Try again after synchronization. + Invitation permissions saved. + Could not change invitation permissions. + The request timed out. Reopen settings to check the result. + Refresh + Apply diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index f23806be7..a806be50e 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -39,6 +39,7 @@ #include "../../../lib/strbuf.h" #include "../../../src/chat/chat_member.h" #include "../../../src/chat/group_avatar.h" +#include "../../../src/chat/group_policy.h" #include "../../../src/member_files/member_files.h" #include "../../../src/chat/invite_build.h" #include "../../../src/chat/chat_setting.h" @@ -3416,6 +3417,25 @@ static void android_avatar_snapshot(void* arg) { u_free(event); u_free(req); } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeGroupPolicy( + JNIEnv* env, jobject thiz, jlong id, jlong group, jboolean all, jstring token, jstring keep) { + (void)thiz; + const char* t = token ? jni_utf8(env, token) : NULL; + const char* k = keep ? jni_utf8(env, keep) : NULL; + if (!id || group <= 0 || !k || strlen(k) > 1024 * 1024 || (token && !t) || (t && strlen(t) != 64)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "Android policy invalid request id=%lld group=%lld", (long long)id, (long long)group); + u_free((void*)t); u_free((void*)k); return JNI_FALSE; + } + struct group_policy_request* q = u_calloc(1, sizeof(*q) + strlen(k) + 1); + if (q) { + q->id = id; q->group = group; q->all_members = all ? 1 : 0; + if (t) memcpy(q->token, t, 65); + memcpy(q->keep, k, strlen(k) + 1); + } else DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "Android policy request allocation failed"); + u_free((void*)t); u_free((void*)k); + return q && !bridge_post_request(group_policy_gui_command, q, &q->inst) ? JNI_TRUE : JNI_FALSE; +} + /* Только операции аватарок; файловые пути ограничены каталогом avatars. Владение req переходит uasync. */ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeAvatarOperation( JNIEnv* env, jobject thiz, jint op, jlong id, jlong group, jlong server, jlong owner,