Browse Source

Show channel member inviter in Android details

master
evgeny 1 week ago
parent
commit
93c0c5bdfc
  1. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  2. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  3. 32
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  4. 3
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  5. 3
      tools/chatgui-android/app/src/main/res/values/strings.xml
  6. 61
      tools/chatgui-android/jni_bridge/android_jni_bridge.c

7
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -92,7 +92,8 @@ data class MemberDetail(val nodeId: Long, val name: String = "",
val directPresence: Boolean = false, val directUp: Boolean = false, val directPresence: Boolean = false, val directUp: Boolean = false,
val indirectPresence: Boolean = false, val indirectUp: Boolean = false, val indirectPresence: Boolean = false, val indirectUp: Boolean = false,
val bgpPresence: Boolean = false, val bgpRtt: Int = 0, val bgpPresence: Boolean = false, val bgpRtt: Int = 0,
val bgpNodes: Int = 0) val bgpNodes: Int = 0, val channelId: String = "",
val inviterId: Long? = null, val inviterName: String = "")
data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "", data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "",
val port: Int = 0, val socketId: Int = 0, val addrType: Int = 0, val rtt: Int = 0) { val port: Int = 0, val socketId: Int = 0, val addrType: Int = 0, val rtt: Int = 0) {
@ -459,7 +460,9 @@ class ChatRepository {
online = obj.optInt("on", 0) != 0, online = obj.optInt("on", 0) != 0,
lastSeen = obj.optLong("ls", 0), lastSeen = obj.optLong("ls", 0),
created = obj.optLong("cr", 0), created = obj.optLong("cr", 0),
addresses = addrs addresses = addrs, channelId = channelId,
inviterId = if (obj.isNull("inviter_id")) null else obj.getLong("inviter_id"),
inviterName = obj.optString("inviter_name", "")
) )
} catch (e: Exception) { Log.w("utun-gui", "getMemberDetail error", e) } } catch (e: Exception) { Log.w("utun-gui", "getMemberDetail error", e) }
return null return null

10
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt

@ -320,6 +320,16 @@ private fun MemberDetailCard(
Text(stringResource(R.string.offline), fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.offline), fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
val inviter = when (detail.inviterId) {
null -> stringResource(R.string.member_inviter_unknown)
0L -> stringResource(R.string.member_inviter_root)
else -> {
val id = "[${detail.inviterId.toString(16).padStart(16, '0')}]"
if (detail.inviterName.isNotEmpty()) "${detail.inviterName} $id" else id
}
}
Text(stringResource(R.string.member_inviter, inviter), fontSize = 13.sp, lineHeight = 16.sp,
color = MaterialTheme.colorScheme.onSurface)
} }
if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text(stringResource(R.string.connect)) } if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text(stringResource(R.string.connect)) }
TextButton(onClick = onEdit, enabled = canEdit) { Text(stringResource(R.string.edit)) } TextButton(onClick = onEdit, enabled = canEdit) { Text(stringResource(R.string.edit)) }

32
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -134,6 +134,8 @@ class ChatViewModel : ViewModel() {
private val _selectedMember = MutableStateFlow<MemberDetail?>(null) private val _selectedMember = MutableStateFlow<MemberDetail?>(null)
val selectedMember: StateFlow<MemberDetail?> = _selectedMember val selectedMember: StateFlow<MemberDetail?> = _selectedMember
private var memberDetailTarget: Pair<String, Long>? = null
private var memberDetailJob: kotlinx.coroutines.Job? = null
private val _memberLinks = MutableStateFlow<MemberLinks>(MemberLinks()) private val _memberLinks = MutableStateFlow<MemberLinks>(MemberLinks())
val memberLinks: StateFlow<MemberLinks> = _memberLinks val memberLinks: StateFlow<MemberLinks> = _memberLinks
@ -313,6 +315,7 @@ class ChatViewModel : ViewModel() {
val chId = String(data, 1, chLen) val chId = String(data, 1, chLen)
val cur = _currentChannel.value val cur = _currentChannel.value
if (cur != null && cur.id == chId) refreshMembers(chId) if (cur != null && cur.id == chId) refreshMembers(chId)
refreshSelectedMemberDetail(chId)
} }
} }
8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */ 8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */
@ -502,8 +505,9 @@ class ChatViewModel : ViewModel() {
} }
} }
val sel = _selectedMember.value val sel = _selectedMember.value
if (sel != null && sel.nodeId == w.nodeId) if (sel != null && sel.channelId == chId && sel.nodeId == w.nodeId)
_selectedMember.value = sel.copy(online = w.online != 0) _selectedMember.value = sel.copy(online = w.online != 0)
refreshSelectedMemberDetail(chId, w.nodeId)
} }
25 -> { /* MEMBER_REMOVED: [ch_id_len:1][ch_id:var][node_id:8] */ 25 -> { /* MEMBER_REMOVED: [ch_id_len:1][ch_id:var][node_id:8] */
if (data == null || data.size < 10) return if (data == null || data.size < 10) return
@ -515,8 +519,10 @@ class ChatViewModel : ViewModel() {
val cur = _currentChannel.value val cur = _currentChannel.value
if (cur != null && cur.id == String(data, 1, cl)) if (cur != null && cur.id == String(data, 1, cl))
_members.value = _members.value.filter { it.nodeId != nodeId } _members.value = _members.value.filter { it.nodeId != nodeId }
if (_selectedMember.value?.nodeId == nodeId) if (memberDetailTarget == (chId to nodeId)) {
memberDetailJob?.cancel(); memberDetailTarget = null
_selectedMember.value = null _selectedMember.value = null
} else refreshSelectedMemberDetail(chId, nodeId)
} }
22 -> { /* MEMBER_DETAIL: бинарный снапшот links+sockets */ 22 -> { /* MEMBER_DETAIL: бинарный снапшот links+sockets */
if (data == null || data.size < 12) return if (data == null || data.size < 12) return
@ -936,6 +942,9 @@ class ChatViewModel : ViewModel() {
fun selectChannel(channel: Channel) { fun selectChannel(channel: Channel) {
val r = repo ?: return val r = repo ?: return
if (_currentChannel.value?.id != channel.id) {
memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null
}
_currentChannel.value = channel _currentChannel.value = channel
_radioActive.value = (radioChannelId == channel.id) _radioActive.value = (radioChannelId == channel.id)
if (radioChannelId != channel.id) _radioTalking.value = null if (radioChannelId != channel.id) _radioTalking.value = null
@ -1097,10 +1106,21 @@ class ChatViewModel : ViewModel() {
} }
fun showMemberDetail(channelId: String, nodeId: Long) { fun showMemberDetail(channelId: String, nodeId: Long) {
memberDetailTarget = channelId to nodeId
refreshSelectedMemberDetail(channelId)
}
/* Перечитываем родителя при изменении участника или его инвайтера, сохраняя актуальный live-статус. */
private fun refreshSelectedMemberDetail(channelId: String, changedNodeId: Long? = null) {
val target = memberDetailTarget ?: return
if (target.first != channelId || _currentChannel.value?.id != channelId) return
if (changedNodeId != null && changedNodeId != target.second && changedNodeId != _selectedMember.value?.inviterId) return
val r = repo ?: return val r = repo ?: return
val ns = _nodeStatuses.value[nodeId] memberDetailJob?.cancel()
viewModelScope.launch { memberDetailJob = viewModelScope.launch {
val d = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) } val d = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, target.second) }
if (memberDetailTarget != target || _currentChannel.value?.id != channelId) return@launch
val ns = _nodeStatuses.value[target.second]
if (d != null && ns != null) _selectedMember.value = d.copy( if (d != null && ns != null) _selectedMember.value = d.copy(
online = ns.online, bestRtt = ns.bestRtt, online = ns.online, bestRtt = ns.bestRtt,
directPresence = ns.directPresence, directUp = ns.directUp, directPresence = ns.directPresence, directUp = ns.directUp,
@ -1116,6 +1136,7 @@ class ChatViewModel : ViewModel() {
val r = repo ?: return val r = repo ?: return
r.setMemberNick(channelId, nodeId, nick) r.setMemberNick(channelId, nodeId, nick)
refreshMembers(channelId) refreshMembers(channelId)
refreshSelectedMemberDetail(channelId, nodeId)
} }
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
@ -1440,6 +1461,7 @@ class ChatViewModel : ViewModel() {
} }
fun clearState() { fun clearState() {
memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null
memberRadios.clear() memberRadios.clear()
dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null
_attachmentPreparations.value = emptyMap() _attachmentPreparations.value = emptyMap()

3
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -327,6 +327,9 @@
<item quantity="many">BGP: %1$d узлов</item> <item quantity="many">BGP: %1$d узлов</item>
<item quantity="other">BGP: %1$d узла</item> <item quantity="other">BGP: %1$d узла</item>
</plurals> </plurals>
<string name="member_inviter">Пригласил: %1$s</string>
<string name="member_inviter_unknown">неизвестно</string>
<string name="member_inviter_root">корень группы</string>
<string name="start_on_boot">"Запускать после перезагрузки"</string> <string name="start_on_boot">"Запускать после перезагрузки"</string>
<string name="start_on_boot_hint">"Запускать включённый сервис после первой разблокировки телефона после перезагрузки."</string> <string name="start_on_boot_hint">"Запускать включённый сервис после первой разблокировки телефона после перезагрузки."</string>
<string name="start_on_boot_save_failed">"Не удалось сохранить настройку автозапуска"</string> <string name="start_on_boot_save_failed">"Не удалось сохранить настройку автозапуска"</string>

3
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -325,6 +325,9 @@
<item quantity="one">BGP: %1$d node</item> <item quantity="one">BGP: %1$d node</item>
<item quantity="other">BGP: %1$d nodes</item> <item quantity="other">BGP: %1$d nodes</item>
</plurals> </plurals>
<string name="member_inviter">Invited by: %1$s</string>
<string name="member_inviter_unknown">unknown</string>
<string name="member_inviter_root">group root</string>
<string name="start_on_boot">"Start after reboot"</string> <string name="start_on_boot">"Start after reboot"</string>
<string name="start_on_boot_hint">"Start the enabled service after the first unlock following a reboot."</string> <string name="start_on_boot_hint">"Start the enabled service after the first unlock following a reboot."</string>
<string name="start_on_boot_save_failed">"Could not save the startup setting"</string> <string name="start_on_boot_save_failed">"Could not save the startup setting"</string>

61
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -797,6 +797,21 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
return json; return json;
} }
/* Не продолжаем сборку JSON после усечения строки: pos должен оставаться внутри буфера. */
static int member_detail_append(char* buf, size_t size, size_t* pos, const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
int written = vsnprintf(buf + *pos, size - *pos, fmt, ap);
va_end(ap);
if (written < 0 || (size_t)written >= size - *pos) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "member detail: JSON append failed used=%zu capacity=%zu required=%d",
*pos, size, written);
return -1;
}
*pos += (size_t)written;
return 0;
}
char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id) { char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance(); struct UTUN_INSTANCE* inst = instance_lite_get_instance();
sqlite3* db = chat_core_get_db(inst); sqlite3* db = chat_core_get_db(inst);
@ -817,16 +832,50 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i
int64_t last_seen = sqlite3_column_int64(st, 1); int64_t last_seen = sqlite3_column_int64(st, 1);
int64_t created = sqlite3_column_int64(st, 2); int64_t created = sqlite3_column_int64(st, 2);
char* esc_name = json_escape_alloc(name && name[0] ? name : ""); char* esc_name = json_escape_alloc(name && name[0] ? name : "");
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, int rc = member_detail_append(buf, sizeof(buf), &pos,
",\"n\":\"%s\",\"on\":0,\"ls\":%lld,\"cr\":%lld", ",\"n\":\"%s\",\"on\":0,\"ls\":%lld,\"cr\":%lld",
esc_name, (long long)last_seen, (long long)created); esc_name, (long long)last_seen, (long long)created);
u_free(esc_name); u_free(esc_name);
if (rc < 0) { sqlite3_finalize(st); return u_strdup("{}"); }
} }
sqlite3_finalize(st); sqlite3_finalize(st);
} }
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
snprintf(buf + pos, sizeof(buf) - pos, ",\"adr\":["); pos += strlen(buf + pos); /* Родитель дерева приглашений относится к каналу; без подписи это ещё неизвестная связь. */
char sql[512];
snprintf(sql, sizeof(sql),
"SELECT p.signed_by, length(p.signature), p.source, COALESCE(NULLIF(i.local_nick,''),n.name,'')"
" FROM \"%s\" p LEFT JOIN \"%s\" i ON i.node_id=p.signed_by"
" LEFT JOIN nodes n ON n.node_id=p.signed_by WHERE p.node_id=?", peers_tbl, peers_tbl);
int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_ROW) {
int known = sqlite3_column_type(st, 0) != SQLITE_NULL && sqlite3_column_int(st, 1) == 64 &&
sqlite3_column_int(st, 2) == 0;
uint64_t inviter_id = (uint64_t)sqlite3_column_int64(st, 0);
const char* inviter_name = known && inviter_id ? (const char*)sqlite3_column_text(st, 3) : "";
char* escaped = json_escape_alloc(inviter_name);
if (!escaped) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "member invitation: name allocation failed ch=%s node=%016llx",
channel_id, (unsigned long long)node_id);
sqlite3_finalize(st); return u_strdup("{}");
}
char inviter_json[32] = "null";
if (known) snprintf(inviter_json, sizeof(inviter_json), "%llu", (unsigned long long)inviter_id);
int append_rc = member_detail_append(buf, sizeof(buf), &pos,
",\"inviter_id\":%s,\"inviter_name\":\"%s\"", inviter_json, escaped);
u_free(escaped);
if (append_rc < 0) { sqlite3_finalize(st); return u_strdup("{}"); }
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "member invitation: ch=%s node=%016llx known=%d inviter=%016llx name=%s",
channel_id, (unsigned long long)node_id, known, (unsigned long long)inviter_id, inviter_name);
} else {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "member invitation: read failed ch=%s node=%016llx rc=%d: %s",
channel_id, (unsigned long long)node_id, rc, sqlite3_errmsg(db));
}
sqlite3_finalize(st); st = NULL;
if (member_detail_append(buf, sizeof(buf), &pos, ",\"adr\":[") < 0) return u_strdup("{}");
if (sqlite3_prepare_v2(db, if (sqlite3_prepare_v2(db,
"SELECT family, protocol, address, port, socket_id, addr_type, COALESCE(rtt,0)" "SELECT family, protocol, address, port, socket_id, addr_type, COALESCE(rtt,0)"
@ -857,15 +906,17 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i
const char* pn; const char* pn;
if (proto & 0x04) pn = "TCP+REALITY"; if (proto & 0x04) pn = "TCP+REALITY";
else switch (proto) { case 1: pn = "UDP"; break; case 2: pn = "TCP"; break; default: pn = "UDP/TCP"; break; } else switch (proto) { case 1: pn = "UDP"; break; case 2: pn = "TCP"; break; default: pn = "UDP/TCP"; break; }
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, if (member_detail_append(buf, sizeof(buf), &pos,
"%s{\"fm\":%d,\"pt\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"si\":%d,\"at\":%d,\"rt\":%d}", "%s{\"fm\":%d,\"pt\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"si\":%d,\"at\":%d,\"rt\":%d}",
af ? "" : ",", family, pn, addr_str[0] ? addr_str : "?", port, sid, at, rtt); af ? "" : ",", family, pn, addr_str[0] ? addr_str : "?", port, sid, at, rtt) < 0) {
sqlite3_finalize(st); return u_strdup("{}");
}
af = 0; af = 0;
} }
sqlite3_finalize(st); sqlite3_finalize(st);
} }
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, "]}"); if (member_detail_append(buf, sizeof(buf), &pos, "]}") < 0) return u_strdup("{}");
return u_strdup(buf); return u_strdup(buf);
} }

Loading…
Cancel
Save