Add Android group invitation permissions and selective re-signing
This commit is contained in:
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) }
|
||||
|
||||
+124
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user