diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 510c1ab4..0df23a67 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/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 indirectPresence: Boolean = false, val indirectUp: Boolean = false, 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 = "", 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, lastSeen = obj.optLong("ls", 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) } return null diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 69fc4c33..7c4b28bb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/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) } } + 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)) } TextButton(onClick = onEdit, enabled = canEdit) { Text(stringResource(R.string.edit)) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 495b215a..4e4647ee 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -134,6 +134,8 @@ class ChatViewModel : ViewModel() { private val _selectedMember = MutableStateFlow(null) val selectedMember: StateFlow = _selectedMember + private var memberDetailTarget: Pair? = null + private var memberDetailJob: kotlinx.coroutines.Job? = null private val _memberLinks = MutableStateFlow(MemberLinks()) val memberLinks: StateFlow = _memberLinks @@ -313,6 +315,7 @@ class ChatViewModel : ViewModel() { val chId = String(data, 1, chLen) val cur = _currentChannel.value if (cur != null && cur.id == chId) refreshMembers(chId) + refreshSelectedMemberDetail(chId) } } 8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */ @@ -502,8 +505,9 @@ class ChatViewModel : ViewModel() { } } 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) + refreshSelectedMemberDetail(chId, w.nodeId) } 25 -> { /* MEMBER_REMOVED: [ch_id_len:1][ch_id:var][node_id:8] */ if (data == null || data.size < 10) return @@ -515,8 +519,10 @@ class ChatViewModel : ViewModel() { val cur = _currentChannel.value if (cur != null && cur.id == String(data, 1, cl)) _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 + } else refreshSelectedMemberDetail(chId, nodeId) } 22 -> { /* MEMBER_DETAIL: бинарный снапшот links+sockets */ if (data == null || data.size < 12) return @@ -936,6 +942,9 @@ class ChatViewModel : ViewModel() { fun selectChannel(channel: Channel) { val r = repo ?: return + if (_currentChannel.value?.id != channel.id) { + memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null + } _currentChannel.value = channel _radioActive.value = (radioChannelId == channel.id) if (radioChannelId != channel.id) _radioTalking.value = null @@ -1097,10 +1106,21 @@ class ChatViewModel : ViewModel() { } 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 ns = _nodeStatuses.value[nodeId] - viewModelScope.launch { - val d = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) } + memberDetailJob?.cancel() + memberDetailJob = viewModelScope.launch { + 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( online = ns.online, bestRtt = ns.bestRtt, directPresence = ns.directPresence, directUp = ns.directUp, @@ -1116,6 +1136,7 @@ class ChatViewModel : ViewModel() { val r = repo ?: return r.setMemberNick(channelId, nodeId, nick) refreshMembers(channelId) + refreshSelectedMemberDetail(channelId, nodeId) } fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, @@ -1440,6 +1461,7 @@ class ChatViewModel : ViewModel() { } fun clearState() { + memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null memberRadios.clear() dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null _attachmentPreparations.value = emptyMap() 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 aa68238c..319f2a9d 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 @@ -327,6 +327,9 @@ BGP: %1$d узлов BGP: %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 4b41783c..69e0cd4a 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -325,6 +325,9 @@ BGP: %1$d node BGP: %1$d nodes + Invited by: %1$s + unknown + group root "Start after reboot" "Start the enabled service after the first unlock following a reboot." "Could not save the startup setting" diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index de224da3..3edfe4ad 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/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; } +/* Не продолжаем сборку 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) { struct UTUN_INSTANCE* inst = instance_lite_get_instance(); 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 created = sqlite3_column_int64(st, 2); 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", esc_name, (long long)last_seen, (long long)created); u_free(esc_name); + if (rc < 0) { sqlite3_finalize(st); return u_strdup("{}"); } } sqlite3_finalize(st); } 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, "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; if (proto & 0x04) pn = "TCP+REALITY"; 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}", - 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; } 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); }