Add Android group invitation permissions and selective re-signing

This commit is contained in:
evgeny committed 2026-11-03 16:10:00 +01:00
1 parent 24683009db
commit 01c2aaad70
6 files changed
+177

No files matched your search

@@ -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()
@@ -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) }
@@ -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<String>): 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<JSONObject?>(null) }
var desired by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(true) }
var selected by remember { mutableStateOf(setOf<String>()) }
var status by remember { mutableStateOf<Int?>(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)) } })
}
@@ -707,4 +707,18 @@
<string name="devgroup_name">Имя группы устройств</string>
<string name="devgroup_rename_failed">Не удалось переименовать группу устройств</string>
<string name="devgroup_collapse">Свернуть устройства</string>
<string name="group_policy_title">Кто может приглашать участников</string>
<string name="group_policy_all">Все участники группы</string>
<string name="group_policy_admins">Только владелец и администраторы</string>
<string name="group_policy_explanation">При следующей сверке участники с неподходящей цепочкой приглашений покинут группу. Отметьте тех, кого нужно переподписать ключом группы, чтобы сохранить их участие.</string>
<string name="group_policy_owner_only">Изменить настройку может только суперадминистратор.</string>
<string name="group_policy_affected">Под ограничение попадают: %1$d</string>
<string name="group_policy_inviter">Пригласил: %1$s</string>
<string name="group_policy_conflict">Состав группы изменился. Проверьте обновлённый список и подтвердите снова.</string>
<string name="group_policy_sync">Идёт сверка участников. Повторите после её завершения.</string>
<string name="group_policy_saved">Права приглашения сохранены.</string>
<string name="group_policy_failed">Не удалось изменить права приглашения.</string>
<string name="group_policy_timeout">Время ожидания истекло. Откройте настройки снова, чтобы проверить результат.</string>
<string name="group_policy_refresh">Обновить</string>
<string name="group_policy_apply">Применить</string>
</resources>
@@ -706,4 +706,18 @@
<string name="devgroup_name">Device group name</string>
<string name="devgroup_rename_failed">Could not rename device group</string>
<string name="devgroup_collapse">Collapse devices</string>
<string name="group_policy_title">Who may invite members</string>
<string name="group_policy_all">All group members</string>
<string name="group_policy_admins">Owner and administrators only</string>
<string name="group_policy_explanation">Members whose invitation chain no longer qualifies leave at the next synchronization. Select members to re-sign with the group key and keep them.</string>
<string name="group_policy_owner_only">Only a superadministrator can change this setting.</string>
<string name="group_policy_affected">Members affected: %1$d</string>
<string name="group_policy_inviter">Invited by: %1$s</string>
<string name="group_policy_conflict">Membership changed. Review the refreshed list and apply again.</string>
<string name="group_policy_sync">Membership is synchronizing. Try again after synchronization.</string>
<string name="group_policy_saved">Invitation permissions saved.</string>
<string name="group_policy_failed">Could not change invitation permissions.</string>
<string name="group_policy_timeout">The request timed out. Reopen settings to check the result.</string>
<string name="group_policy_refresh">Refresh</string>
<string name="group_policy_apply">Apply</string>
</resources>
@@ -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,