From 23368a5bbe587ba741384a228061922e74fddbcc Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 2 Oct 2026 17:18:24 +0300 Subject: [PATCH] Add Android boot startup, lazy settings, emoji and DM calls Add battery optimization controls and configurable boot startup. Keep message sounds in the application and render general settings lazily to avoid ANRs. Add animated emoji controls and live BGP availability for direct-message calls on Android and desktop. --- src/call/call.c | 18 +- src/call/call.h | 3 +- src/chat/chat_event.c | 2 +- src/chat/chat_event.h | 2 + src/dm/dm_core.c | 61 ++- src/dm/dm_core.h | 3 + tests/test_call.c | 82 +++- tools/chatgui-android/app/build.gradle.kts | 4 + .../app/src/main/AndroidManifest.xml | 10 + .../java/com/utun/chat/ChatApplication.kt | 19 + .../main/java/com/utun/chat/MainActivity.kt | 16 + .../data/BatteryOptimizationPermission.kt | 30 ++ .../java/com/utun/chat/data/ChatRepository.kt | 2 +- .../java/com/utun/chat/data/ConfigProvider.kt | 25 ++ .../main/java/com/utun/chat/data/NativeLib.kt | 2 + .../com/utun/chat/headless/BootReceiver.kt | 36 ++ .../com/utun/chat/headless/HeadlessService.kt | 6 +- .../utun/chat/ui/components/AnimatedEmoji.kt | 130 +++++++ .../components/BatteryOptimizationSetting.kt | 68 ++++ .../ui/components/ConversationComposer.kt | 9 +- .../chat/ui/components/EmojiPickerPanel.kt | 65 ++++ .../utun/chat/ui/components/MessageBubble.kt | 71 +++- .../utun/chat/ui/components/PeerStatusDot.kt | 21 + .../utun/chat/ui/screens/ChannelListScreen.kt | 2 + .../com/utun/chat/ui/screens/DmChatScreen.kt | 20 +- .../utun/chat/ui/screens/FirstLaunchScreen.kt | 3 + .../utun/chat/ui/screens/SettingsScreen.kt | 360 +++++++++++------- .../com/utun/chat/viewmodel/ChatViewModel.kt | 67 +++- .../app/src/main/res/values-ru/strings.xml | 3 + .../app/src/main/res/values/strings.xml | 3 + .../jni_bridge/android_jni_bridge.c | 16 + tools/chatgui/src/channeldelegate.cpp | 8 +- tools/chatgui/src/channeldelegate.h | 1 + tools/chatgui/src/channellist.cpp | 51 +++ tools/chatgui/src/channellist.h | 7 + tools/chatgui/src/mainwindow.cpp | 25 +- tools/chatgui/src/messagelist.cpp | 70 ++++ tools/chatgui/src/messagelist.h | 8 + tools/chatgui/transport/gui_bridge.h | 5 + tools/chatgui/transport/gui_bridge_impl.cpp | 11 + 40 files changed, 1165 insertions(+), 180 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/BatteryOptimizationPermission.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/headless/BootReceiver.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/BatteryOptimizationSetting.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/EmojiPickerPanel.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PeerStatusDot.kt diff --git a/src/call/call.c b/src/call/call.c index 2c5f4c4b..709abcba 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -13,6 +13,7 @@ #include "call.h" #include "call_proto.h" +#include "../dm/dm_core.h" #include "../utun_instance.h" #include "../chat/chat_event.h" #include "../routing_layer/etcp_router.h" @@ -894,7 +895,22 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod return; } - uint64_t group_id = strtoull(ch_id, NULL, 10); + uint64_t group_id = 0; + char route_ch[64]; + if (!strncmp(ch_id, "dm:", 3)) { + uint64_t peer = 0; + int available = dm_call_route(inst, ch_id + 3, &peer, &group_id); + if (!available || peer != peer_node_id) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM call rejected conv=%s requested_peer=%llu actual_peer=%llu available=%d", + ch_id + 3, (unsigned long long)peer_node_id, (unsigned long long)peer, available); + uint8_t evt[9]; + memcpy(evt, &out_call_id, 8); evt[8] = 1; + chat_event_post(inst, CHAT_EVT_CALL_ERROR, evt, sizeof(evt)); + return; + } + snprintf(route_ch, sizeof(route_ch), "%llu", (unsigned long long)group_id); + ch_id = route_ch; + } else group_id = strtoull(ch_id, NULL, 10); if (!call_is_online(inst, ch_id, peer_node_id)) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: peer 0x%016llx offline in group 0x%016llx — call not started", CALL_ID, (unsigned long long)peer_node_id, (unsigned long long)group_id); diff --git a/src/call/call.h b/src/call/call.h index 6a0f8ad4..c18c84ac 100644 --- a/src/call/call.h +++ b/src/call/call.h @@ -26,7 +26,8 @@ void call_destroy(struct UTUN_INSTANCE* inst); /* перед dm_core_destroy */ /* ── сигналинг (uasync-поток) ── */ -/* Caller: начать звонок. out_call_id — случайный uint64, генерит вызывающий. */ +/* Caller: ch_id — группа либо "dm:" (BGP-группа выбирается при запуске). + * out_call_id — случайный uint64, генерит вызывающий. */ void call_start (struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id); /* Callee: ответить/отклонить. hangup — любая сторона в любой момент. */ diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 5625c329..7591090c 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -49,7 +49,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [40]="CALL_ERROR", [41]="DM_MESSAGES", [42]="CALL_PATH", [43]="RADIO_TALK", [44]="CALL_CONNECTION", - [45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", + [45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", [47]="DM_CALL_ROUTE", }; const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 4093c9f0..09e8d54a 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -68,6 +68,8 @@ extern "C" { #define CHAT_EVT_DM_CONVERSATIONS 45 /* [JSON:utf8] — последние 200 личных бесед */ #define CHAT_EVT_ATTACHMENT_PREPARATION 46 /* [target_len:1][target:var][job_uuid:16][state:1], 0=start 1=ready 2=failed */ +#define CHAT_EVT_DM_CALL_ROUTE 47 /* [conv_id_len:1][conv_id:var][peer_node_id:8][group_id:8], 0 = нет BGP-пути */ + typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); void chat_event_set_handler(struct UTUN_INSTANCE* inst, chat_event_handler_fn handler); diff --git a/src/dm/dm_core.c b/src/dm/dm_core.c index 9fa839fd..057ff5fc 100644 --- a/src/dm/dm_core.c +++ b/src/dm/dm_core.c @@ -137,7 +137,8 @@ static int dm_shared_group_id(struct dm_state* dm, uint64_t peer_id, uint64_t* o } /* Выбрать общую область с действительно живым маршрутом, включая BGP-транзит. */ -int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, uint64_t* group_id) { +static int dm_find_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, + uint64_t* group_id, int bgp_only) { if (!inst || !inst->topo_sqlite_db || !group_id) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: invalid route lookup", DM_ID); return 0; @@ -156,8 +157,13 @@ int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred snprintf(ch, sizeof(ch), "%llu", (unsigned long long)gid); if (!topo_node_sqlite_member_in_channel(inst->topo_sqlite_db, ch, inst->node_id) || !topo_node_sqlite_member_in_channel(inst->topo_sqlite_db, ch, peer)) continue; - struct ETCP_ROUTER_PATH path = etcp_router_get_path(inst, gid, peer, ETCP_RT_ID_DM); - if (!path.next_hop_node_id) continue; + if (bgp_only) { + struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid); + if (!topo_group_find_conn_for_node(g, peer)) continue; + } else { + struct ETCP_ROUTER_PATH path = etcp_router_get_path(inst, gid, peer, ETCP_RT_ID_DM); + if (!path.next_hop_node_id) continue; + } *group_id = gid; found = 1; break; @@ -166,6 +172,10 @@ int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred return found; } +int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, uint64_t* group_id) { + return dm_find_route_group(inst, peer, preferred, group_id, 0); +} + /* Router владеет entry после вызова, в том числе при ошибке. */ static int dm_route_send(struct dm_state* dm, uint64_t group_id, uint64_t dst, uint8_t subcmd, const uint8_t* body, size_t body_len) { @@ -1022,6 +1032,51 @@ void dm_send_trampoline(void* arg) { DM_ID, conv); } +/* Единая BGP-проверка для кнопки PM и запуска звонка; только uasync-поток. */ +int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id) { + struct dm_state* dm = dm_of(inst); + if (!peer || !group_id || !conv_id || !dm || !dm->db) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM route: invalid request or stopped service"); return 0; + } + *peer = 0; *group_id = 0; + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(dm->db, "SELECT peer_node_id,group_id FROM dm_conversations WHERE conv_id=?", + -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "PM route: query failed: %s", sqlite3_errmsg(dm->db)); return 0; + } + sqlite3_bind_text(st, 1, conv_id, -1, SQLITE_STATIC); + int rc = sqlite3_step(st); + uint64_t preferred = 0; + if (rc == SQLITE_ROW) { + *peer = (uint64_t)sqlite3_column_int64(st, 0); + preferred = (uint64_t)sqlite3_column_int64(st, 1); + } else if (rc == SQLITE_DONE) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM route: conversation missing conv=%s", conv_id); + } else { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "PM route: read failed conv=%s: %s", conv_id, sqlite3_errmsg(dm->db)); + } + sqlite3_finalize(st); + int available = *peer && *peer != inst->node_id && dm_find_route_group(inst, *peer, preferred, group_id, 1); + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "PM route: conv=%s peer=%llu preferred=%llu group=%llu available=%d", + conv_id, (unsigned long long)*peer, (unsigned long long)preferred, (unsigned long long)*group_id, available); + return available; +} + +void dm_call_route_trampoline(void* arg) { + struct dm_messages_req* req = arg; + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "PM route: empty request"); return; } + uint64_t peer = 0, group = 0; + dm_call_route(req->inst, req->conv_id, &peer, &group); + size_t len = strlen(req->conv_id); + uint8_t evt[1 + 63 + 16]; + evt[0] = (uint8_t)len; + memcpy(evt + 1, req->conv_id, len); + memcpy(evt + 1 + len, &peer, 8); + memcpy(evt + 9 + len, &group, 8); + chat_event_post(req->inst, CHAT_EVT_DM_CALL_ROUTE, evt, (int)(17 + len)); + u_free(req); +} + /* Ограниченный JSON-снимок доставляется GUI асинхронно, без доступа UI к instance/SQLite. */ void dm_messages_trampoline(void* arg) { struct dm_messages_req* req = arg; diff --git a/src/dm/dm_core.h b/src/dm/dm_core.h index f0e06724..7487874d 100644 --- a/src/dm/dm_core.h +++ b/src/dm/dm_core.h @@ -120,6 +120,9 @@ struct dm_messages_req { char conv_id[64]; }; void dm_messages_trampoline(void* arg); +/* Живой BGP-маршрут PM; group_id=0 в снимке означает недоступность. Все вызовы в uasync. */ +int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id); +void dm_call_route_trampoline(void* arg); /* аргумент: dm_messages_req */ struct dm_conversations_req { struct UTUN_INSTANCE* inst; }; void dm_conversations_trampoline(void* arg); diff --git a/tests/test_call.c b/tests/test_call.c index ddb9a1c5..128ee2bd 100644 --- a/tests/test_call.c +++ b/tests/test_call.c @@ -4,15 +4,18 @@ // Прямое соединение A←B, оба в CHAT-группе. Master state-machine на таймере. // // Сценарий: -// 1. happy path: A call_start → B INCOMING → B accept → обе ACTIVE (ACCEPTED) +// 1. PM BGP: живой/закрывающийся/исчезнувший путь, выбор общей группы и повторная проверка. +// 2. happy path из PM: A call_start → B INCOMING → B accept → обе ACTIVE (ACCEPTED) // → A hangup → обе ENDED. -// 2. decline: A call_start → B INCOMING → B decline → обе ENDED(DECLINE). +// 3. decline из PM: A call_start → B INCOMING → B decline → обе ENDED(DECLINE). #include "chat_core.h" #include "chat_sync.h" #include "chat_event.h" #include "member_sync.h" #include "../call/call.h" +#include "../dm/dm_core.h" +#include "../dm/dm_crypto.h" #include "../call/call_proto.h" #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_group.h" @@ -77,7 +80,8 @@ struct call_ctx { /* записи событий CHAT_EVT_CALL_* на узел */ struct call_evt { - int incoming, ringing, accepted, ended, declined; + int incoming, ringing, accepted, ended, declined, errors; + uint64_t route_peer, route_group; uint64_t incoming_call_id; uint64_t accepted_call_id; uint64_t ended_call_id; @@ -230,6 +234,15 @@ static void call_evt_handler(struct UTUN_INSTANCE* inst, int type, const uint8_t struct call_evt* ev = (struct call_evt*)inst->test_user_ptr; if (!ev) return; switch (type) { + case CHAT_EVT_CALL_ERROR: + ev->errors++; + break; + case CHAT_EVT_DM_CALL_ROUTE: + if (len >= 17 && len == 17 + data[0]) { + memcpy(&ev->route_peer, data + 1 + data[0], 8); + memcpy(&ev->route_group, data + 9 + data[0], 8); + } + break; case CHAT_EVT_CALL_INCOMING: { if (len < 1 + 8 + 8) return; int ch_len = data[0]; @@ -314,6 +327,49 @@ static void do_setup(struct UTUN_INSTANCE* inst, int role) { } } +/* Кнопка и запуск PM одинаково реагируют на потерю BGP, даже при живом ETCP. */ +static int test_pm_route(struct UTUN_INSTANCE* inst, uint64_t peer, char target[64]) { + char conv[24]; + snprintf(conv, sizeof(conv), "%llu", (unsigned long long)dm_derive_conv_id(inst->node_id, peer)); + snprintf(target, 64, "dm:%s", conv); + if (dm_start(inst, peer, g_sh.x_pub[IDX_B], g_sh.ed_pub[IDX_B], "B", CH_ID)) return 0; + uint64_t actual_peer = 0, group = 0, expected = strtoull(CH_ID, NULL, 10); + if (!dm_call_route(inst, conv, &actual_peer, &group) || actual_peer != peer || group != expected) return 0; + struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, expected); + struct TOPO_GROUP_NODE* node = topo_node_find_by_id(g, peer); + if (!node || !node->paths) return 0; + struct ETCP_CONN* route_conn = topo_node_best_path(node)->conn; + route_conn->close_requested = 1; + int closed_unavailable = !dm_call_route(inst, conv, &actual_peer, &group) && group == 0; + route_conn->close_requested = 0; + if (!closed_unavailable) return 0; + struct ll_queue* paths = node->paths; + node->paths = NULL; + int ok = !dm_call_route(inst, conv, &actual_peer, &group) && actual_peer == peer && group == 0; + struct dm_messages_req* req = u_calloc(1, sizeof(*req)); + if (req) { + req->inst = inst; snprintf(req->conv_id, sizeof(req->conv_id), "%s", conv); + dm_call_route_trampoline(req); + ok &= g_ev[IDX_A].route_peer == peer && g_ev[IDX_A].route_group == 0; + } else ok = 0; + int before = g_ev[IDX_A].errors; + call_start(inst, target, peer, 123); + ok &= g_ev[IDX_A].errors == before + 1; + node->paths = paths; + ok &= dm_call_route(inst, conv, &actual_peer, &group) && group == expected; + /* Сохранённая область недоступна: выбирается другой общий канал с живым BGP. */ + char sql[160]; + snprintf(sql, sizeof(sql), "UPDATE dm_conversations SET group_id=1 WHERE conv_id='%s'", conv); + ok &= sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK; + ok &= dm_call_route(inst, conv, &actual_peer, &group) && group == expected; + before = g_ev[IDX_A].errors; + call_start(inst, "dm:missing", peer, 124); + /* ID другого пира не должен позволять позвонить из чужой беседы. */ + call_start(inst, target, peer ^ 1, 126); + ok &= g_ev[IDX_A].errors == before + 2; + return ok; +} + /* ── master state-machine ── */ static void call_tick(void* arg) { @@ -339,13 +395,18 @@ static void call_tick(void* arg) { case P_WAIT_BGP: if (bgp_has(A, nb) && bgp_has(B, na)) t->phase = P_CALL1_START; break; - case P_CALL1_START: + case P_CALL1_START: { memset(ea, 0, sizeof(*ea)); memset(eb, 0, sizeof(*eb)); + char target[64]; + if (!test_pm_route(A, nb, target)) { + fprintf(stderr, "PM route availability/recheck failed\n"); t->result = 2; uasync_stop(t->ua); return; + } t->call_id = ((uint64_t)1 << 40) | (uint64_t)(rand() & 0xFFFFF); - call_start(A, CH_ID, nb, t->call_id); + call_start(A, target, nb, t->call_id); t->phase = P_WAIT_INCOMING1; break; + } case P_WAIT_INCOMING1: if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_ACCEPT; break; @@ -392,13 +453,18 @@ static void call_tick(void* arg) { case P_WAIT_ENDED1: if (ea->ended && eb->ended && ea->ended_call_id == t->call_id) t->phase = P_CALL2_START; break; - case P_CALL2_START: + case P_CALL2_START: { memset(ea, 0, sizeof(*ea)); memset(eb, 0, sizeof(*eb)); + char target[64]; + if (!test_pm_route(A, nb, target)) { + fprintf(stderr, "PM route availability/recheck failed\n"); t->result = 2; uasync_stop(t->ua); return; + } t->call_id = ((uint64_t)2 << 40) | (uint64_t)(rand() & 0xFFFFF); - call_start(A, CH_ID, nb, t->call_id); + call_start(A, target, nb, t->call_id); t->phase = P_WAIT_INCOMING2; break; + } case P_WAIT_INCOMING2: if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_DECLINE; break; @@ -456,7 +522,7 @@ int main(int argc, char** argv) { TEST("live transport: mixed links, reverse, none, intermediary"); if (test_transport_direction()) OK(); else FAIL("incorrect transport direction"); - TEST("single-thread: call happy path + decline (2 nodes)"); { + TEST("PM BGP availability/recheck + audio call + decline"); { struct UASYNC* ua = uasync_create(); if (!ua) { FAIL("uasync_create failed"); return 1; } diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index 014a11dd..b0790686 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -38,6 +38,9 @@ android { compose = true } + // Общие TGS-анимации с desktop-чатгуи, без копирования ресурсов. + sourceSets["main"].assets.srcDir("../../chatgui/resources/animations") + composeOptions { kotlinCompilerExtensionVersion = "1.5.5" } @@ -62,6 +65,7 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.foundation:foundation") + implementation("com.airbnb.android:lottie-compose:6.3.0") implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.core:core-ktx:1.15.0") diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index 6dd5c66e..c6120457 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -15,6 +15,8 @@ + + @@ -85,6 +87,14 @@ android:value="true" /> + + + + + + diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 4c53101f..3f20fde0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -16,6 +16,8 @@ import com.utun.chat.data.ChatEventCallback import com.utun.chat.data.CallController import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import java.nio.ByteBuffer +import java.nio.ByteOrder object AppEventHandler : ChatEventCallback { private val mainHandler = Handler(Looper.getMainLooper()) @@ -23,12 +25,29 @@ object AppEventHandler : ChatEventCallback { val events: SharedFlow> = _events override fun onEvent(type: Int, data: ByteArray?) { + if (type == 1 || type == 32) playMessageSound(type, data) if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) { mainHandler.post { CallController.onEvent(type, data) } } Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}") _events.tryEmit(type to data) } + + // Звук живёт в приложении: работает без Activity и не повторяется при replay событий в UI. + private fun playMessageSound(type: Int, data: ByteArray?) { + val length = data?.firstOrNull()?.toInt()?.and(0xFF) ?: 0 + if (data == null || length == 0 || data.size < 1 + length + 8) { + LogManager.addLog("WARN", "Sound", "Invalid message event: type=$type length=$length size=${data?.size}") + return + } + val author = ByteBuffer.wrap(data, 1 + length, 8).order(ByteOrder.LITTLE_ENDIAN).long + // JNI-callback выполняется в потоке ядра; читаем ID до передачи на main, пока ядро существует. + if (author == NativeLib.getMyNodeId()) return + val id = String(data, 1, length, Charsets.UTF_8) + val soundId = if (type == 32) "dm:$id" else id + LogManager.addLog("DEBUG", "Sound", "Incoming message sound dispatched: type=$type target=$soundId author=$author") + mainHandler.post { NotificationSound.play(soundId) } + } } class ChatApplication : Application() { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 99956440..b7610dd1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -12,7 +12,9 @@ import com.utun.chat.data.MicrophonePermission import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.os.SystemClock import android.view.KeyEvent +import android.view.MotionEvent import android.view.WindowManager import com.utun.chat.data.CallController import com.utun.chat.data.CallPhase @@ -57,6 +59,18 @@ class MainActivity : AppCompatActivity() { enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, VideoRecord, PhotoRecord, Call, Dm } private var overLock = false + private var inputScreen = "setup" + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + val started = SystemClock.uptimeMillis() + try { + return super.dispatchTouchEvent(event) + } finally { + val elapsed = SystemClock.uptimeMillis() - started + if (elapsed >= 100) LogManager.addLog("WARN", "UI_INPUT", + "Slow touch: screen=$inputScreen action=${event.actionMasked} x=${event.x} y=${event.y} elapsedMs=$elapsed") + } + } + private var microphoneAction: (() -> Unit)? = null private var microphoneReason = "" private val overlayPermissionRequester: () -> Unit = { @@ -184,6 +198,8 @@ class MainActivity : AppCompatActivity() { // После смерти процесса ViewModel уже новый; сохранённый экран может потерять свою беседу или звонок. LaunchedEffect(screen) { + inputScreen = screen.name + LogManager.addLog("INFO", "MAIN", "UI screen=$screen") val unavailable = when (screen) { Screen.Chat, Screen.MemberList, Screen.PhotoRecord -> vm.currentChannel.value == null Screen.Dm -> vm.currentDm.value == null diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BatteryOptimizationPermission.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BatteryOptimizationPermission.kt new file mode 100644 index 00000000..ed24b6ba --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BatteryOptimizationPermission.kt @@ -0,0 +1,30 @@ +package com.utun.chat.data + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings + +/* Состоянием исключения владеет Android; отдельную настройку приложения не сохраняем. */ +object BatteryOptimizationPermission { + fun granted(context: Context): Boolean = + context.getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(context.packageName) + + fun request(context: Context, source: String): Boolean { + val actions = listOf(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + for (action in actions) { + val intent = Intent(action) + if (action == Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) intent.data = Uri.parse("package:${context.packageName}") + try { + context.startActivity(intent) + LogManager.addLog("INFO", "PERMISSION", "Battery optimization request: source=$source action=$action package=${context.packageName}") + return true + } catch (e: Exception) { + LogManager.addLog("WARN", "PERMISSION", "Cannot open battery optimization screen: source=$source action=$action error=$e") + } + } + LogManager.addLog("ERROR", "PERMISSION", "Battery optimization request failed: source=$source package=${context.packageName}") + return false + } +} 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 725113f5..453a1f1e 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 @@ -57,7 +57,7 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va val peersOnline: Int = 0) data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "", - val lastTs: Long = 0) { + val lastTs: Long = 0, val online: Boolean = false) { val displayName: String get() = peerName.ifBlank { "0x%016x".format(peerNodeId) } } data class Message(val id: Long, val author: String, val text: String, val ts: Long, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 9e50842c..564ea557 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -52,6 +52,7 @@ class ConfigProvider(private val context: Context) { private val firstLaunchDone = booleanPreferencesKey("first_launch_done") private val connectionModeKey = stringPreferencesKey("connection_mode") private val serviceEnabledKey = booleanPreferencesKey("service_enabled") + private val startOnBootKey = booleanPreferencesKey("start_on_boot") private val soundEnabledKey = booleanPreferencesKey("sound_enabled") private val mutedChannelsKey = stringSetPreferencesKey("muted_channels") private val callAudioRouteKey = stringPreferencesKey("call_audio_route") @@ -267,6 +268,30 @@ class ConfigProvider(private val context: Context) { context.dataStore.edit { prefs -> prefs[serviceEnabledKey] = enabled } } + val startOnBootFlow: Flow = context.dataStore.data.map { it[startOnBootKey] ?: true } + + suspend fun setStartOnBoot(enabled: Boolean) { + context.dataStore.edit { prefs -> prefs[startOnBootKey] = enabled } + LogManager.addLog("INFO", "CONFIG", "Start on boot setting saved: enabled=$enabled") + } + + // Одним снимком проверяем все условия автозапуска. + suspend fun shouldStartOnBoot(): Boolean { + val prefs = context.dataStore.data.first() + val setupDone = prefs[firstLaunchDone] ?: false + val startOnBoot = prefs[startOnBootKey] ?: true + val serviceEnabled = prefs[serviceEnabledKey] ?: true + val reason = when { + !setupDone -> "setup_incomplete" + !startOnBoot -> "autostart_disabled" + !serviceEnabled -> "service_disabled" + else -> "start" + } + LogManager.addLog("INFO", "BOOT", + "Boot decision: setupDone=$setupDone startOnBoot=$startOnBoot serviceEnabled=$serviceEnabled reason=$reason") + return setupDone && startOnBoot && serviceEnabled + } + /* ── Notification sound (global toggle + per-group mute) ── */ fun getSoundEnabled(): Boolean = runBlocking { 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 9a3f662a..4026a08b 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 @@ -185,6 +185,7 @@ object NativeLib { fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) } fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) } fun dmRequestConversations() { nativeDmRequestConversations() } + fun dmRequestCallRoute(convId: String) { nativeDmRequestCallRoute(convId) } fun dmRequestMessages(convId: String) { nativeDmRequestMessages(convId) } fun dmSendFile(convId: String, path: String, name: String): Boolean = nativeDmSendFile(convId, path, name) @@ -298,6 +299,7 @@ object NativeLib { private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String) private external fun nativeDmSend(convId: String, text: String) private external fun nativeDmRequestConversations() + private external fun nativeDmRequestCallRoute(convId: String) private external fun nativeDmRequestMessages(convId: String) private external fun nativeDmSendFile(convId: String, path: String, name: String): Boolean } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/BootReceiver.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/BootReceiver.kt new file mode 100644 index 00000000..e570980b --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/BootReceiver.kt @@ -0,0 +1,36 @@ +package com.utun.chat.headless + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import com.utun.chat.ChatApplication +import com.utun.chat.data.LogManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** BOOT_COMPLETED приходит после разблокировки, когда настройки и БД уже доступны. */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) { + LogManager.addLog("WARN", "BOOT", "Unexpected boot receiver action=${intent.action}") + return + } + LogManager.addLog("INFO", "BOOT", "Boot completed broadcast received") + val pending = goAsync() + val appContext = context.applicationContext + CoroutineScope(Dispatchers.IO).launch { + try { + if (!ChatApplication.instance.configProvider.shouldStartOnBoot()) return@launch + val serviceIntent = Intent(appContext, HeadlessService::class.java).setAction(Intent.ACTION_BOOT_COMPLETED) + ContextCompat.startForegroundService(appContext, serviceIntent) + LogManager.addLog("INFO", "BOOT", "Boot service start requested: service=${HeadlessService::class.java.name}") + } catch (e: Exception) { + LogManager.addLog("ERROR", "BOOT", "Boot service start failed: $e") + } finally { + pending.finish() + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt index 34d0cd87..65e8a9ab 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt @@ -66,7 +66,11 @@ class HeadlessService : Service() { override fun onBind(intent: Intent?): IBinder? = null - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val source = if (intent == null) "sticky_restart" else intent.action ?: "manual" + LogManager.addLog("INFO", "SRV", "Service start command: source=$source startId=$startId flags=$flags ready=${NativeLib.isReady()}") + return START_STICKY + } private fun notification(): android.app.Notification { val intent = Intent(this, MainActivity::class.java) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt new file mode 100644 index 00000000..5e96d1da --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt @@ -0,0 +1,130 @@ +package com.utun.chat.ui.components + +import com.utun.chat.R +import androidx.compose.ui.res.stringResource +import android.icu.text.BreakIterator +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.TextUnit +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import com.airbnb.lottie.compose.rememberLottieAnimatable +import com.utun.chat.data.LogManager +import java.util.Locale +import kotlin.math.ceil + +private const val EMOJI_PLAYBACK_MIN_MS = 4000f + +// Соответствие Unicode и TGS такое же, как в desktop src/emoji.cpp. +private val animatedEmojiAssets = linkedMapOf( + "✨" to "sparkles_emoji.tgs", + "🏳️" to "white_flag_emoji.tgs", + "👋" to "greeting.tgs", + "✋" to "stop.tgs", + "🤖" to "robot.tgs" +) +val animatedEmojiCharacters: List = animatedEmojiAssets.keys.toList() +private val normalizedEmojiAssets = animatedEmojiAssets.mapKeys { it.key.removeSuffix("\uFE0F") } +private fun animationAsset(emoji: String): String? = normalizedEmojiAssets[emoji.removeSuffix("\uFE0F")] + +/** Загрузка и кеширование TGS выполняются Lottie вне UI-потока; фон останавливает воспроизведение. */ +@Composable +fun AnimatedEmoji(emoji: String, fontSize: TextUnit, modifier: Modifier = Modifier, color: Color = Color.Unspecified, + loop: Boolean = false) { + val asset = animationAsset(emoji) + if (asset == null) { + Text(emoji, fontSize = fontSize, color = color, modifier = modifier) + return + } + val result = rememberLottieComposition(LottieCompositionSpec.Asset(asset)) + val composition = result.value + var replay by remember(asset) { mutableIntStateOf(0) } + LaunchedEffect(asset, composition, result.error) { + if (result.error != null) LogManager.addLog("ERROR", "Emoji", "animation load failed: asset=$asset error=${result.error}") + else if (composition != null) { + LogManager.addLog("DEBUG", "Emoji", "animation ready: asset=$asset frames=${composition.durationFrames} fps=${composition.frameRate}") + composition.warnings.forEach { LogManager.addLog("WARN", "Emoji", "animation warning: asset=$asset $it") } + } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + var playing by remember(lifecycle) { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) } + DisposableEffect(lifecycle, asset) { + val observer = LifecycleEventObserver { _, _ -> + val resumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + if (playing != resumed) LogManager.addLog("DEBUG", "Emoji", "animation visibility: asset=$asset resumed=$resumed") + playing = resumed + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + val replayModifier = if (loop) Modifier else Modifier.clickable(enabled = composition != null, onClickLabel = stringResource(R.string.replay)) { + replay++ + LogManager.addLog("DEBUG", "Emoji", "animation replay requested: asset=$asset replay=$replay") + } + Box(modifier.then(replayModifier).semantics { contentDescription = emoji }, contentAlignment = Alignment.Center) { + if (composition == null) Text(emoji, fontSize = fontSize, color = color) + else key(asset, replay, loop) { + val animation = rememberLottieAnimatable() + // Останавливаемся только на границе цикла, после минимум четырёх секунд воспроизведения. + val iterations = if (loop) LottieConstants.IterateForever + else ceil(EMOJI_PLAYBACK_MIN_MS / composition.duration.coerceAtLeast(1f)).toInt().coerceAtLeast(1) + LaunchedEffect(composition, playing) { + if (!playing || (!loop && animation.composition == composition && animation.isAtEnd)) return@LaunchedEffect + LogManager.addLog("DEBUG", "Emoji", + "animation run: asset=$asset replay=$replay cycles=$iterations cycle_ms=${composition.duration} " + + "iteration=${animation.iteration} progress=${animation.progress}") + animation.animate(composition, iterations = iterations, initialProgress = animation.progress, + ignoreSystemAnimationsDisabled = true) + LogManager.addLog("DEBUG", "Emoji", + "animation finished: asset=$asset replay=$replay cycles=${animation.iteration} progress=${animation.progress}") + } + LottieAnimation(composition, progress = { animation.progress }, modifier = Modifier.fillMaxSize()) + } + } +} + +/** Полные графемы сохраняют модификаторы кожи и ZWJ-последовательности обычными Unicode-смайликами. */ +@Composable +fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier = Modifier) { + val annotated = remember(text) { + buildAnnotatedString { + val iterator = BreakIterator.getCharacterInstance(Locale.US) + iterator.setText(text) + var start = iterator.first() + var end = iterator.next() + while (end != BreakIterator.DONE) { + val cluster = text.substring(start, end) + val asset = animationAsset(cluster) + if (asset == null) append(cluster) else appendInlineContent(asset, cluster) + start = end + end = iterator.next() + } + } + } + val inline = remember(fontSize, color) { + animatedEmojiAssets.values.associateWith { + InlineTextContent(Placeholder(fontSize * 1.3f, fontSize * 1.3f, PlaceholderVerticalAlign.Center)) { emoji -> + AnimatedEmoji(emoji, fontSize, Modifier.fillMaxSize(), color) + } + } + } + Text(annotated, inlineContent = inline, fontSize = fontSize, color = color, modifier = modifier) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/BatteryOptimizationSetting.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/BatteryOptimizationSetting.kt new file mode 100644 index 00000000..63e2c2ae --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/BatteryOptimizationSetting.kt @@ -0,0 +1,68 @@ +package com.utun.chat.ui.components + +import com.utun.chat.R +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.utun.chat.data.BatteryOptimizationPermission +import com.utun.chat.data.LogManager + +@Composable +fun BatteryOptimizationSetting(source: String, enabled: Boolean = true) { + val context = LocalContext.current + var granted by remember(context) { mutableStateOf(BatteryOptimizationPermission.granted(context)) } + var lastLoggedState by remember(context) { mutableStateOf(null) } + var requestPending by rememberSaveable { mutableStateOf(false) } + var error by rememberSaveable { mutableStateOf(null) } + + // Результат системного диалога определяем повторной проверкой, а не resultCode. + LifecycleResumeEffect(context, source) { + granted = BatteryOptimizationPermission.granted(context) + if (lastLoggedState != granted) { + LogManager.addLog("INFO", "PERMISSION", "Battery optimization exemption: source=$source granted=$granted") + lastLoggedState = granted + } + if (requestPending) { + LogManager.addLog(if (granted) "INFO" else "WARN", "PERMISSION", "Battery optimization request completed: source=$source granted=$granted") + requestPending = false + } + if (granted) error = null + onPauseOrDispose { } + } + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.background_unrestricted), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.battery_optimization_hint), + style = MaterialTheme.typography.bodySmall) + Text(stringResource(if (granted) R.string.allowed else R.string.not_allowed), style = MaterialTheme.typography.bodyMedium, + color = if (granted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + if (!granted) { + Button(enabled = enabled && !requestPending, onClick = { + error = null + granted = BatteryOptimizationPermission.granted(context) + if (!granted) { + requestPending = true + if (!BatteryOptimizationPermission.request(context, source)) { + requestPending = false + error = R.string.battery_settings_failed + } + } + }) { Text(stringResource(R.string.grant)) } + } + error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt index c7b72542..fa4a3caf 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.utun.chat.data.ConversationTarget import com.utun.chat.data.LogManager @@ -32,7 +33,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o val context = LocalContext.current val scope = rememberCoroutineScope() val activeTarget by rememberUpdatedState(target) - var text by rememberSaveable(target.nativeId) { mutableStateOf("") } + var text by rememberSaveable(target.nativeId, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } var selected by remember(target) { mutableStateOf(null) } var pickerTarget by remember { mutableStateOf(null) } var preparing by remember(target) { mutableStateOf(false) } @@ -78,7 +79,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o if (preparing || preparation?.jobs?.isNotEmpty() == true) Text(stringResource(R.string.media_preparing), modifier = Modifier.padding(8.dp)) else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) - InputBar( + key(target) { InputBar( text = text, onTextChange = { text = it }, enabled = !preparing, onSend = { val attachment = selected @@ -104,7 +105,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o preparing = false } } - if (text.isNotBlank()) { onSendText(text); text = "" } + if (text.text.isNotBlank()) { onSendText(text.text); text = TextFieldValue() } }, isRecording = recording, isRecordingLocked = locked, recordingDurationMs = duration, recordingLevel = level, onPttPressed = { viewModel.startVoiceRecording(target) }, @@ -115,7 +116,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o onClearAttachment = { selected = null }, isVideoMode = videoMode, onToggleMode = { viewModel.toggleVideoMode() }, onVideoRecordOpen = onRecordVideo, modifier = Modifier.navigationBarsPadding() - ) + ) } } if (menu) ModalBottomSheet(onDismissRequest = { menu = false }) { ListItem(headlineContent = { Text(stringResource(R.string.select_file)) }, modifier = Modifier.clickable { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/EmojiPickerPanel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/EmojiPickerPanel.kt new file mode 100644 index 00000000..526a4653 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/EmojiPickerPanel.kt @@ -0,0 +1,65 @@ +package com.utun.chat.ui.components + +import com.utun.chat.R +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.utun.chat.data.LogManager + +private data class EmojiCategory(val nameRes: Int, val emoji: List, val animated: Boolean = false) + +private val emojiCategories = listOf( + EmojiCategory(R.string.emoji_faces, ("😀 😃 😄 😁 😆 😅 😂 🤣 😊 🙂 🙃 😉 😍 🥰 😘 😋 😛 😜 🤪 🤗 🤭 🤫 🤔 😎 " + + "🥳 😏 😒 😔 😢 😭 😤 😠 😡 🤯 😱 😨 😰 😴 🤤 🤢 🤮 🤧 🥺 😇 🤡 💩 👻 🤖").split(" ")), + EmojiCategory(R.string.emoji_animations, animatedEmojiCharacters, animated = true), + EmojiCategory(R.string.emoji_gestures, ("👍 👎 👏 🙌 👐 🤲 🙏 🤝 ✌️ 🤞 🤟 🤘 👌 🤌 👈 👉 " + + "👆 👇 ☝️ ✋ 👋 🤚 🖐️ ✊ 👊 🤛 🤜 💪 🫶 👀 🧠 🫂").split(" ")), + EmojiCategory(R.string.emoji_hearts, ("❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❤️‍🔥 ❤️‍🩹 💕 💞 💓 💗 " + + "💖 💘 💝 💟 ❣️ 💋 💯 💢 💥 💫 💦 💤 ✨ ⭐ 🌟 🔥").split(" ")), + EmojiCategory(R.string.emoji_nature, ("🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🐔 🐧 🐦 🦆 " + + "🦉 🦋 🐝 🐢 🐬 🐳 🐟 🌸 🌹 🌻 🌲 🌴 🍀 ☀️ 🌙 🌈 ☁️ 🌧️ ❄️").split(" ")), + EmojiCategory(R.string.emoji_food, ("🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍒 🍑 🍍 🥝 🥑 🍅 🥕 🌽 🥐 🍞 🧀 🥚 " + + "🍳 🥞 🍔 🍟 🍕 🌭 🌮 🍣 🍜 🍿 🍩 🍪 🎂 🍫 🍬 🍦 ☕ 🍵 🥤 🍺 🥂").split(" ")), + EmojiCategory(R.string.emoji_other, ("🎉 🎊 🎁 🎈 🎀 🏆 🥇 ⚽ 🏀 🎮 🎲 🎵 🎶 🎸 🎤 🎧 📷 🎬 💡 📚 ✏️ 💼 " + + "💰 💎 🔑 🔒 🛠️ ⚙️ 📱 💻 🚗 ✈️ 🚀 🏠 🌍 ✅ ❌ ❓ ❗ ⚠️ ♻️ 🏳️ 🏁").split(" ")) +) + +/** Панель остаётся открытой для вставки нескольких смайликов подряд. */ +@Composable +fun EmojiPickerPanel(onSelect: (String) -> Unit) { + var categoryIndex by remember { mutableStateOf(0) } + val gridState = rememberLazyGridState() + LaunchedEffect(categoryIndex) { gridState.scrollToItem(0) } + Column(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + emojiCategories.forEachIndexed { index, category -> + FilterChip(selected = categoryIndex == index, onClick = { + categoryIndex = index + LogManager.addLog("DEBUG", "Composer", "emoji category selected: ${category.nameRes}") + }, label = { Text(stringResource(category.nameRes)) }) + } + } + LazyVerticalGrid(columns = GridCells.Adaptive(48.dp), state = gridState, modifier = Modifier.fillMaxWidth().height(200.dp)) { + items(emojiCategories[categoryIndex].emoji, key = { it }) { emoji -> + val description = stringResource(R.string.insert_emoji, emoji) + TextButton(onClick = { onSelect(emoji) }, contentPadding = PaddingValues(0.dp), + modifier = Modifier.height(48.dp).semantics { contentDescription = description }) { + if (emojiCategories[categoryIndex].animated) AnimatedEmoji(emoji, 26.sp, Modifier.size(32.dp), loop = true) + else Text(emoji, fontSize = 26.sp) + } + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 3600cb69..bd52506c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -9,6 +9,7 @@ import android.icu.text.BreakIterator import android.media.MediaMetadataRetriever import androidx.core.content.ContextCompat import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.BackHandler import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image @@ -18,12 +19,18 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.InsertEmoticon +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import com.utun.chat.ui.theme.ChatColors import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap @@ -34,11 +41,16 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.data.Message +import com.utun.chat.data.LogManager import com.utun.chat.data.NativeLib import com.utun.chat.data.PhotoUtil import com.utun.chat.viewmodel.ChatViewModel @@ -107,8 +119,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = alignment ) { - Text( - message.text.trim(), + EmojiText( + text = message.text.trim(), fontSize = if (emojiCount == 1) 100.sp else 51.sp, color = textColor ) @@ -137,7 +149,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage) isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile) else -> { - Text(message.text, fontSize = 17.sp, color = textColor) + EmojiText(message.text, fontSize = 17.sp, color = textColor) } } @@ -487,8 +499,8 @@ private const val HOLD_THRESHOLD_MS = 250L @Composable fun InputBar( - text: String, - onTextChange: (String) -> Unit, + text: TextFieldValue, + onTextChange: (TextFieldValue) -> Unit, onSend: () -> Unit, enabled: Boolean = true, isRecording: Boolean = false, @@ -511,6 +523,22 @@ fun InputBar( ) { val ctx = LocalContext.current val density = LocalDensity.current + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val inputFocus = remember { FocusRequester() } + var emojiPanel by remember { mutableStateOf(false) } + fun closeEmojiPanel(reason: String) { + if (!emojiPanel) return + emojiPanel = false + LogManager.addLog("DEBUG", "Composer", "emoji panel closed: $reason") + } + BackHandler(enabled = emojiPanel) { closeEmojiPanel("back") } + LaunchedEffect(isRecording, enabled) { + if (isRecording || !enabled) closeEmojiPanel(if (isRecording) "recording" else "disabled") + } + DisposableEffect(Unit) { + onDispose { if (emojiPanel) LogManager.addLog("DEBUG", "Composer", "emoji panel closed: conversation left") } + } val lockThresholdPx = with(density) { LOCK_SWIPE_THRESHOLD_DP.dp.toPx() } var hasMicPerm by remember { @@ -606,8 +634,30 @@ fun InputBar( value = text, onValueChange = onTextChange, enabled = enabled, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).focusRequester(inputFocus).onFocusChanged { + if (it.isFocused) closeEmojiPanel("text input focused") + }, placeholder = { Text(stringResource(R.string.message_placeholder)) }, + leadingIcon = { + IconButton(enabled = enabled, onClick = { + if (emojiPanel) { + closeEmojiPanel("keyboard button") + inputFocus.requestFocus() + keyboard?.show() + } else { + // Compose сворачивает выделение при потере фокуса; сохраняем диапазон для вставки. + focusManager.clearFocus() + onTextChange(text.copy(composition = null)) + keyboard?.hide() + emojiPanel = true + LogManager.addLog("DEBUG", "Composer", + "emoji panel opened: selection=${text.selection.start}..${text.selection.end}") + } + }) { + Icon(if (emojiPanel) Icons.Outlined.Keyboard else Icons.Outlined.InsertEmoticon, + contentDescription = stringResource(if (emojiPanel) R.string.open_keyboard else R.string.open_emoji)) + } + }, maxLines = 4 ) } @@ -622,7 +672,7 @@ fun InputBar( IconButton(onClick = onVoiceSend) { Text("\u25B6", fontSize = 20.sp, color = Color(0xFF81C784)) } - } else if (!isRecording && (text.isNotBlank() || hasAttachment)) { + } else if (!isRecording && (text.text.isNotBlank() || hasAttachment)) { /* Send button — round, primary color */ val sendColor = MaterialTheme.colorScheme.primary Box( @@ -717,6 +767,13 @@ fun InputBar( } } } + if (emojiPanel && !isRecording && enabled) EmojiPickerPanel(onSelect = { emoji -> + val start = text.selection.min + val end = text.selection.max + val updated = text.text.replaceRange(start, end, emoji) + onTextChange(TextFieldValue(updated, selection = TextRange(start + emoji.length))) + LogManager.addLog("DEBUG", "Composer", "emoji inserted: start=$start replaced=${end - start} length=${emoji.length}") + }) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PeerStatusDot.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PeerStatusDot.kt new file mode 100644 index 00000000..639b00b1 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PeerStatusDot.kt @@ -0,0 +1,21 @@ +package com.utun.chat.ui.components + +import com.utun.chat.R +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +@Composable +fun PeerStatusDot(online: Boolean, modifier: Modifier = Modifier) { + val description = stringResource(if (online) R.string.online else R.string.not_online) + Box(modifier.size(12.dp).background(if (online) Color(0xFF4CAF50) else Color(0xFFE53935), CircleShape) + .semantics { contentDescription = description }) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index e4cd5c37..11223c48 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Build import androidx.compose.material3.* import androidx.compose.runtime.* +import com.utun.chat.ui.components.PeerStatusDot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -170,6 +171,7 @@ fun ChannelListScreen( Row(Modifier.fillMaxWidth().padding(12.dp)) { Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { Text("@", fontSize = 22.sp, color = Color(0xFFD2A8FF)) + PeerStatusDot(dm.online, Modifier.align(Alignment.TopStart).offset(x = (-4).dp, y = (-6).dp)) } Spacer(Modifier.width(10.dp)) Column { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt index fed543ec..fd2774ed 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt @@ -8,10 +8,13 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import com.utun.chat.ui.components.PeerStatusDot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.utun.chat.data.* @@ -31,6 +34,8 @@ fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatV onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) { val target = ConversationTarget.Pm(conv.convId) val playback by viewModel.playbackState.collectAsState() + val callAvailable by viewModel.dmCallAvailable.collectAsState() + val callState by viewModel.callState.collectAsState() val state = rememberLazyListState() val context = LocalContext.current val scope = rememberCoroutineScope() @@ -47,7 +52,8 @@ fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatV } ?: throw IllegalStateException(AppLanguage.text(R.string.file_open_failed)) } } catch (e: Exception) { - LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = AppLanguage.text(R.string.file_save_failed) + LogManager.addLog("ERROR", "PM", "save failed: ${e.message}") + error = AppLanguage.text(R.string.file_save_failed) } } } @@ -55,8 +61,18 @@ fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatV if (messages.lastOrNull()?.isOutgoing == true) state.scrollToItem(0) } Column(Modifier.fillMaxSize().imePadding()) { - TopAppBar(title = { Text(conv.displayName) }, navigationIcon = { + TopAppBar(title = { + Row(verticalAlignment = Alignment.CenterVertically) { + PeerStatusDot(callAvailable) + Spacer(Modifier.width(8.dp)) + Text(conv.displayName) + } + }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } + }, actions = { + IconButton(onClick = { viewModel.startDmCall() }, enabled = callAvailable && callState?.ongoing != true) { + Icon(Icons.Filled.Call, stringResource(if (callAvailable) R.string.call else R.string.peer_unavailable)) + } }) ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(), onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) }, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt index 6691972d..c600fec2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.utun.chat.data.LogManager +import com.utun.chat.ui.components.BatteryOptimizationSetting import com.utun.chat.ui.components.LanguageSetting import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @@ -51,6 +52,8 @@ fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit) Text(it, color = MaterialTheme.colorScheme.error) } Spacer(Modifier.height(24.dp)) + BatteryOptimizationSetting(source = "setup", enabled = !saving) + Spacer(Modifier.height(24.dp)) Button( enabled = validName && !saving, modifier = Modifier.fillMaxWidth(), diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 6502e7a9..74f10747 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -4,6 +4,10 @@ import com.utun.chat.R import androidx.compose.ui.res.stringResource import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -29,6 +33,7 @@ import com.utun.chat.data.LogManager import com.utun.chat.data.ServerEntry import com.utun.chat.data.LocalSocketState import com.utun.chat.headless.HeadlessService +import com.utun.chat.ui.components.BatteryOptimizationSetting import com.utun.chat.ui.components.LanguageSetting import com.utun.chat.viewmodel.ChatViewModel import android.content.Intent @@ -38,6 +43,7 @@ import android.provider.Settings import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleResumeEffect import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -137,6 +143,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool val localSocketStates by vm.localSockets.collectAsState() val serviceEnabled by provider.serviceEnabledFlow.collectAsState(initial = true) + val startOnBoot by provider.startOnBootFlow.collectAsState(initial = true) + var savingStartOnBoot by remember { mutableStateOf(false) } + var startOnBootError by remember { mutableStateOf(false) } LaunchedEffect(Unit) { nodeName = provider.getString("node.name") ?: provider.getDeviceName() @@ -158,7 +167,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool val stored = provider.getString("debug.categories") ?: "" catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap() servers = provider.loadServersFromConfig() - availableIfaces = provider.getAvailableInterfaces() + availableIfaces = allIface.drop(1) connectionMode = provider.getConnectionMode() autoConnect = provider.getInt("chat.group_autoconnect") != 0 } @@ -187,73 +196,95 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool if (lvl.isEmpty()) NativeLib.setDebugLevel(cat, "NONE") else NativeLib.setDebugLevel(cat, lvl) } - Column( - modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), + var ctrlExpanded by remember { mutableStateOf(false) } + val ctrlIfaces = listOf("lo" to "127.0.0.1") + availableIfaces + val listState = rememberLazyListState() + val openedAt = remember { android.os.SystemClock.uptimeMillis() } + LaunchedEffect(Unit) { + withFrameNanos { } + LogManager.addLog("INFO", "SETTINGS", "General settings displayed: elapsedMs=${android.os.SystemClock.uptimeMillis() - openedAt} " + + "visibleItems=${listState.layoutInfo.visibleItemsInfo.size} totalItems=${listState.layoutInfo.totalItemsCount}") + } + + // Создаём и раскладываем только видимые настройки, включая динамические интерфейсы и категории. + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - LanguageSetting() + item(key = "language") { LanguageSetting() } + item(key = "battery") { BatteryOptimizationSetting(source = "settings") } /* Node */ - Text(stringResource(R.string.node), style = MaterialTheme.typography.titleMedium) - OutlinedTextField(nodeName, { v -> onDirty(true); nodeName = v; scope.launch { provider.setValue("node.name", v) } }, - label = { Text(stringResource(R.string.node_name)) }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) - OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } }, - label = { Text(stringResource(R.string.listen_port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) + item(key = "node-title") { Text(stringResource(R.string.node), style = MaterialTheme.typography.titleMedium) } + item(key = "node-name") { + OutlinedTextField(nodeName, { v -> onDirty(true); nodeName = v; scope.launch { provider.setValue("node.name", v) } }, + label = { Text(stringResource(R.string.node_name)) }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) + } + item(key = "listen-port") { + OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } }, + label = { Text(stringResource(R.string.listen_port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) + } /* Control interface */ - var ctrlExpanded by remember { mutableStateOf(false) } - val ctrlIfaces = remember { listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() } - Row(verticalAlignment = Alignment.CenterVertically) { - ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) { - OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true, - label = { Text(stringResource(R.string.control_bind_ip)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) }, - modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) - ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) { - ctrlIfaces.forEach { (n, ip) -> - DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = { - controlBindIp = ip; ctrlBindLabel = "$n ($ip)"; onDirty(true) - scope.launch { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false - }) + item(key = "control") { + Row(verticalAlignment = Alignment.CenterVertically) { + ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) { + OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true, + label = { Text(stringResource(R.string.control_bind_ip)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) { + ctrlIfaces.forEach { (n, ip) -> + DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = { + controlBindIp = ip; ctrlBindLabel = "$n ($ip)"; onDirty(true) + scope.launch { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false + }) + } } } + Spacer(Modifier.width(8.dp)) + OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } }, + label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } - Spacer(Modifier.width(8.dp)) - OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } }, - label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } /* Keys */ - Text(stringResource(R.string.keys), style = MaterialTheme.typography.titleMedium) - OutlinedTextField(pubKey, {}, label = { Text(stringResource(R.string.public_key)) }, readOnly = true, singleLine = true) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { if (pubKey.length == 64) showOverwriteKeys = true else { onDirty(true); vm.requestRegenerateKeys() } }, - enabled = !generatingKeys) { Text(stringResource(if (generatingKeys) R.string.generating else R.string.generate_keys)) } - Button(onClick = { showClearDb = true }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { - Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.clear_db)) + item(key = "keys-title") { Text(stringResource(R.string.keys), style = MaterialTheme.typography.titleMedium) } + item(key = "public-key") { OutlinedTextField(pubKey, {}, label = { Text(stringResource(R.string.public_key)) }, readOnly = true, singleLine = true) } + item(key = "key-actions") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { if (pubKey.length == 64) showOverwriteKeys = true else { onDirty(true); vm.requestRegenerateKeys() } }, + enabled = !generatingKeys) { Text(stringResource(if (generatingKeys) R.string.generating else R.string.generate_keys)) } + Button(onClick = { showClearDb = true }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.clear_db)) + } } } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) + item(key = "network-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } /* ── Connection Mode ── */ - Text(stringResource(R.string.network_interfaces), style = MaterialTheme.typography.titleMedium) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") }, - label = { Text(stringResource(R.string.automatic)) }) - FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") }, - label = { Text(stringResource(R.string.manual)) }) + item(key = "network-title") { Text(stringResource(R.string.network_interfaces), style = MaterialTheme.typography.titleMedium) } + item(key = "connection-mode") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") }, + label = { Text(stringResource(R.string.automatic)) }) + FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") }, + label = { Text(stringResource(R.string.manual)) }) + } } if (connectionMode == "auto") { /* Auto mode: read-only live socket display */ if (localSocketStates.isEmpty()) { - Text(stringResource(R.string.no_sockets), color = MaterialTheme.colorScheme.onSurfaceVariant) + item(key = "no-sockets") { Text(stringResource(R.string.no_sockets), color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { - localSocketStates.forEach { s -> + items(localSocketStates) { s -> val stateColor = when (s.state) { 2 -> Color(0xFF81C784); 1 -> Color(0xFFF1C40F); else -> Color(0xFFFFB4AB) } Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -278,7 +309,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool } } else { /* Manual mode: existing editable server config */ - servers.forEachIndexed { idx, srv -> + itemsIndexed(servers, key = { _, srv -> "server-${srv.id}" }) { idx, srv -> Column(modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically) { var ifExpanded by remember { mutableStateOf(false) } @@ -316,137 +347,186 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool } } Spacer(Modifier.width(4.dp)) - var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) } + var portText by rememberSaveable(srv.id) { mutableStateOf(srv.port.toString()) } OutlinedTextField(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535) save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) }, singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) } + Spacer(Modifier.height(2.dp)) } - Spacer(Modifier.height(2.dp)) } - TextButton(onClick = { - val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1") - val rndPort = (2000..9999).random() - save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort)) - }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.add_interface)) } + item(key = "add-interface") { + TextButton(onClick = { + val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1") + val rndPort = (2000..9999).random() + save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort)) + }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.add_interface)) } + } } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) + item(key = "standby-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } /* ── Standby (background duty-cycle) ── */ - Text(stringResource(R.string.standby), style = MaterialTheme.typography.titleMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(standbyActiveSec, - { v -> standbyActiveSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..3600) { - scope.launch { provider.setValue("chat.standby_active_sec", v) }; NativeLib.setChatSetting("standby_active_sec", v) } }, - label = { Text(stringResource(R.string.standby_active_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), + item(key = "standby-title") { Text(stringResource(R.string.standby), style = MaterialTheme.typography.titleMedium) } + item(key = "standby-duty-cycle") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(standbyActiveSec, + { v -> standbyActiveSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..3600) { + scope.launch { provider.setValue("chat.standby_active_sec", v) }; NativeLib.setChatSetting("standby_active_sec", v) } }, + label = { Text(stringResource(R.string.standby_active_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + OutlinedTextField(standbySleepSec, + { v -> standbySleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) { + scope.launch { provider.setValue("chat.standby_sleep_sec", v) }; NativeLib.setChatSetting("standby_sleep_sec", v) } }, + label = { Text(stringResource(R.string.standby_sleep_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + } + } + item(key = "standby-min-sleep") { + OutlinedTextField(standbyMinSleepSec, + { v -> standbyMinSleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) { + scope.launch { provider.setValue("chat.standby_min_sleep_sec", v) }; NativeLib.setChatSetting("standby_min_sleep_sec", v) } }, + label = { Text(stringResource(R.string.standby_min_sleep_seconds)) }, modifier = Modifier.heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) - OutlinedTextField(standbySleepSec, - { v -> standbySleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) { - scope.launch { provider.setValue("chat.standby_sleep_sec", v) }; NativeLib.setChatSetting("standby_sleep_sec", v) } }, - label = { Text(stringResource(R.string.standby_sleep_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), + } + item(key = "standby-keepalive") { + OutlinedTextField(standbyKeepaliveMs, + { v -> standbyKeepaliveMs = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1000..600000) { + scope.launch { provider.setValue("chat.standby_keepalive_ms", v) }; NativeLib.setChatSetting("standby_keepalive_ms", v) } }, + label = { Text(stringResource(R.string.standby_keepalive_ms)) }, modifier = Modifier.heightIn(min = 40.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } - OutlinedTextField(standbyMinSleepSec, - { v -> standbyMinSleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) { - scope.launch { provider.setValue("chat.standby_min_sleep_sec", v) }; NativeLib.setChatSetting("standby_min_sleep_sec", v) } }, - label = { Text(stringResource(R.string.standby_min_sleep_seconds)) }, modifier = Modifier.heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) - OutlinedTextField(standbyKeepaliveMs, - { v -> standbyKeepaliveMs = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1000..600000) { - scope.launch { provider.setValue("chat.standby_keepalive_ms", v) }; NativeLib.setChatSetting("standby_keepalive_ms", v) } }, - label = { Text(stringResource(R.string.standby_keepalive_ms)) }, modifier = Modifier.heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) - HorizontalDivider(Modifier.padding(vertical = 8.dp)) + item(key = "chat-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } /* ── Chat ── */ - Text(stringResource(R.string.chat), style = MaterialTheme.typography.titleMedium) - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.group_autoconnect), modifier = Modifier.weight(1f)) - Switch(checked = autoConnect, onCheckedChange = { - autoConnect = it - val s = if (it) "1" else "0" - scope.launch { provider.setValue("chat.group_autoconnect", s) } - NativeLib.setChatSetting("group_autoconnect", s) - }) + item(key = "chat-title") { Text(stringResource(R.string.chat), style = MaterialTheme.typography.titleMedium) } + item(key = "autoconnect") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.group_autoconnect), modifier = Modifier.weight(1f)) + Switch(checked = autoConnect, onCheckedChange = { + autoConnect = it + val s = if (it) "1" else "0" + scope.launch { provider.setValue("chat.group_autoconnect", s) } + NativeLib.setChatSetting("group_autoconnect", s) + }) + } } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) + item(key = "debug-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } /* Debug */ - Text(stringResource(R.string.debug), style = MaterialTheme.typography.titleMedium) - DebugLevelDropdown(label = stringResource(R.string.global_level), current = debugLevel.uppercase(), onSelect = setDebug) - OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } }, - label = { Text(stringResource(R.string.log_file)) }, singleLine = true) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(logUdpIp, { v -> - logUdpIp = v; scope.launch { provider.setValue("log_udp.ip", v) } - val p = logUdpPort.toIntOrNull() ?: 0 - if (v.isNotEmpty() && p > 0) NativeLib.setUdpLogTarget(v, p) else NativeLib.setUdpLogTarget("", 0) - }, label = { Text(stringResource(R.string.udp_log_ip)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true) - OutlinedTextField(logUdpPort, { v -> - logUdpPort = v; val p = v.toIntOrNull() ?: 0; scope.launch { provider.setValue("log_udp.port", v) } - if (logUdpIp.isNotEmpty() && p > 0 && p <= 65535) NativeLib.setUdpLogTarget(logUdpIp, p) else NativeLib.setUdpLogTarget("", 0) - }, label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + item(key = "debug-title") { Text(stringResource(R.string.debug), style = MaterialTheme.typography.titleMedium) } + item(key = "debug-level") { DebugLevelDropdown(label = stringResource(R.string.global_level), current = debugLevel.uppercase(), onSelect = setDebug) } + item(key = "log-file") { + OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } }, + label = { Text(stringResource(R.string.log_file)) }, singleLine = true) } - Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories }, - horizontalArrangement = Arrangement.SpaceBetween) { - Text(stringResource(R.string.categories), style = MaterialTheme.typography.titleMedium) - Icon(Icons.Default.ArrowDropDown, stringResource(if (showDebugCategories) R.string.collapse else R.string.expand), modifier = Modifier.padding(end = 4.dp)) + item(key = "log-udp") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(logUdpIp, { v -> + logUdpIp = v; scope.launch { provider.setValue("log_udp.ip", v) } + val p = logUdpPort.toIntOrNull() ?: 0 + if (v.isNotEmpty() && p > 0) NativeLib.setUdpLogTarget(v, p) else NativeLib.setUdpLogTarget("", 0) + }, label = { Text(stringResource(R.string.udp_log_ip)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true) + OutlinedTextField(logUdpPort, { v -> + logUdpPort = v; val p = v.toIntOrNull() ?: 0; scope.launch { provider.setValue("log_udp.port", v) } + if (logUdpIp.isNotEmpty() && p > 0 && p <= 65535) NativeLib.setUdpLogTarget(logUdpIp, p) else NativeLib.setUdpLogTarget("", 0) + }, label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + } + } + item(key = "debug-categories-toggle") { + Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories }, + horizontalArrangement = Arrangement.SpaceBetween) { + Text(stringResource(R.string.categories), style = MaterialTheme.typography.titleMedium) + Icon(Icons.Default.ArrowDropDown, stringResource(if (showDebugCategories) R.string.collapse else R.string.expand), modifier = Modifier.padding(end = 4.dp)) + } } if (showDebugCategories) { - CATEGORIES.forEach { cat -> + items(CATEGORIES, key = { "debug-$it" }) { cat -> val current = catLevels[cat] ?: "" DebugLevelDropdown(label = cat, current = current.ifEmpty { stringResource(R.string.global_level_hint) }, onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true) } } - Spacer(Modifier.height(16.dp)) + item(key = "debug-spacer") { Spacer(Modifier.height(16.dp)) } /* ── Background service ── */ - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - Text(stringResource(R.string.service), style = MaterialTheme.typography.titleMedium) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(if (serviceEnabled) R.string.service_running else R.string.service_stopped), modifier = Modifier.weight(1f), - color = if (serviceEnabled) Color(0xFF81C784) else MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium) + item(key = "service-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + item(key = "service-title") { Text(stringResource(R.string.service), style = MaterialTheme.typography.titleMedium) } + item(key = "start-on-boot") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.start_on_boot)) + Text(stringResource(R.string.start_on_boot_hint), style = MaterialTheme.typography.bodySmall) + } + Switch(checked = startOnBoot, enabled = !savingStartOnBoot, onCheckedChange = { enabled -> + savingStartOnBoot = true + startOnBootError = false + scope.launch { + try { + provider.setStartOnBoot(enabled) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LogManager.addLog("ERROR", "CONFIG", "Start on boot setting save failed: enabled=$enabled error=$e") + startOnBootError = true + } finally { + savingStartOnBoot = false + } + } + }) + } + } + if (startOnBootError) { + item(key = "boot-error") { Text(stringResource(R.string.start_on_boot_save_failed), color = MaterialTheme.colorScheme.error) } + } + item(key = "service-actions") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(if (serviceEnabled) R.string.service_running else R.string.service_stopped), modifier = Modifier.weight(1f), + color = if (serviceEnabled) Color(0xFF81C784) else MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium) + Button(onClick = { + runBlocking { provider.setServiceEnabled(true) } + ContextCompat.startForegroundService(ChatApplication.instance, + Intent(ChatApplication.instance, HeadlessService::class.java)) + }, enabled = !serviceEnabled) { Text(stringResource(R.string.service_start)) } + Button(onClick = { + runBlocking { provider.setServiceEnabled(false) } + ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) + }, enabled = serviceEnabled, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.service_stop)) } + } + } + + item(key = "restart-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + item(key = "restart") { Button(onClick = { - runBlocking { provider.setServiceEnabled(true) } - ContextCompat.startForegroundService(ChatApplication.instance, - Intent(ChatApplication.instance, HeadlessService::class.java)) - }, enabled = !serviceEnabled) { Text(stringResource(R.string.service_start)) } + scope.launch(Dispatchers.IO) { + if (NativeLib.isReady()) { + val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") + NativeLib.restart(path, port, provider.buildConfigText()) + } + } + }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { + Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.instance_restart)) + } + } + item(key = "exit-spacer") { Spacer(Modifier.height(8.dp)) } + item(key = "exit") { Button(onClick = { runBlocking { provider.setServiceEnabled(false) } ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) - }, enabled = serviceEnabled, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.service_stop)) } - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - Button(onClick = { - scope.launch(Dispatchers.IO) { - if (NativeLib.isReady()) { - val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") - NativeLib.restart(path, port, provider.buildConfigText()) - } + onExit() + }, modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.stop_all_exit)) } - }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { - Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.instance_restart)) } - Spacer(Modifier.height(8.dp)) - Button(onClick = { - runBlocking { provider.setServiceEnabled(false) } - ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java)) - onExit() - }, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { - Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.stop_all_exit)) - } - Spacer(Modifier.height(16.dp)) + item(key = "bottom-spacer") { Spacer(Modifier.height(16.dp)) } } /* Dialogs */ 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 fd9ce384..4f570c4c 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 @@ -154,6 +154,9 @@ class ChatViewModel : ViewModel() { private val _currentDm = MutableStateFlow(null) val currentDm: StateFlow = _currentDm + private var dmPresenceRefreshJob: kotlinx.coroutines.Job? = null + private val _dmCallAvailable = MutableStateFlow(false) + val dmCallAvailable: StateFlow = _dmCallAvailable /* Одноразовый сигнал навигации: MainActivity открывает экран DM и сбрасывает. */ private val _dmOpen = MutableStateFlow(null) @@ -258,6 +261,11 @@ class ChatViewModel : ViewModel() { } private fun handleEvent(type: Int, data: ByteArray?) { + if (type == 13) { + dmPresenceRefreshJob?.cancel() + _dmCallAvailable.value = false + _dmConversations.value = _dmConversations.value.map { it.copy(online = false) } + } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() when (type) { 9 -> { /* DB_READY */ if (dbReady) return @@ -278,11 +286,6 @@ class ChatViewModel : ViewModel() { val chLen = data[0].toInt() and 0xFF if (data.size >= 1 + chLen) { val chId = String(data, 1, chLen) - if (data.size >= 1 + chLen + 8) { - val author = java.nio.ByteBuffer.wrap(data, 1 + chLen, 8) - .order(java.nio.ByteOrder.LITTLE_ENDIAN).long - if (author != NativeLib.getMyNodeId()) NotificationSound.play(chId) - } val cur = _currentChannel.value if (cur != null && cur.id == chId) refreshMessages(chId) refreshChannels() @@ -323,6 +326,30 @@ class ChatViewModel : ViewModel() { 4 -> { if (repo != null) { LogManager.addLog("INFO", "VM", "CHANNEL_UPDATED — refreshChannels"); refreshChannels() } else { LogManager.addLog("WARN", "VM", "CHANNEL_UPDATED skipped — repo is NULL") } } 12 -> LogManager.addLog("INFO", "VM", "service started") 13 -> { _attachmentPreparations.value = emptyMap(); LogManager.addLog("INFO", "VM", "service stopped") } + 47 -> { + if (data == null || data.size < 17 || data.size != 17 + (data[0].toInt() and 0xff)) { + LogManager.addLog("WARN", "Call", "invalid PM route snapshot size=${data?.size}"); return + } + val len = data[0].toInt() and 0xff + val conv = String(data, 1, len, Charsets.UTF_8) + val buf = java.nio.ByteBuffer.wrap(data, 1 + len, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val peer = buf.long + val group = buf.long + val available = peer != 0L && group != 0L + _dmConversations.value = _dmConversations.value.map { + if (it.convId != conv) it else { + val online = available && it.peerNodeId == peer + if (it.online != online) LogManager.addLog("INFO", "Call", "PM presence conv=$conv peer=$peer online=$online") + it.copy(online = online) + } + } + val dm = _currentDm.value ?: return + if (conv != dm.convId) return + val canCall = available && peer == dm.peerNodeId + if (_dmCallAvailable.value != canCall) + LogManager.addLog("INFO", "Call", "PM button conv=$conv peer=$peer group=$group enabled=$canCall") + _dmCallAvailable.value = canCall + } 16 -> { /* NODEINFO_UPDATED: [node_id:8][presence:1][up:1][best_rtt:2][bgp_rtt:2][bgp_nodes:2] */ if (data == null || data.size < 12) return val ns = parseNodeStatus(data) ?: return @@ -507,6 +534,8 @@ class ChatViewModel : ViewModel() { if (pend != null) { pendingStartPeer = null _currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second) + _dmCallAvailable.value = false + refreshDmCallRoute() refreshDmMessages() _dmOpen.value = convId LogManager.addLog("INFO", "VM", "DM auto-open conv=$convId") @@ -520,7 +549,6 @@ class ChatViewModel : ViewModel() { val author = java.nio.ByteBuffer.wrap(data, 1 + cl, 8) .order(java.nio.ByteOrder.LITTLE_ENDIAN).long LogManager.addLog("INFO", "VM", "DM_MSG_RECEIVED conv=$convId author=0x%016x".format(author)) - if (author != NativeLib.getMyNodeId()) NotificationSound.play("dm:$convId") val cur = _currentDm.value if (cur != null && cur.convId == convId) refreshDmMessages() refreshDmConversations() @@ -537,7 +565,11 @@ class ChatViewModel : ViewModel() { } 45 -> { if (data == null) return - _dmConversations.value = repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList() + val previous = _dmConversations.value.associateBy { it.convId } + _dmConversations.value = (repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList()).map { + it.copy(online = previous[it.convId]?.online == true) + } + refreshDmCallRoute() } 46 -> { if (data == null || data.size < 18) return @@ -807,6 +839,24 @@ class ChatViewModel : ViewModel() { /* ── DM (личные сообщения) ── */ + private fun refreshDmCallRoute() { + if (dmPresenceRefreshJob?.isActive == true) return + dmPresenceRefreshJob = viewModelScope.launch { + kotlinx.coroutines.delay(50) + if (!NativeLib.isReady()) { _dmCallAvailable.value = false; return@launch } + val conversations = _dmConversations.value + listOfNotNull(_currentDm.value) + conversations.distinctBy { it.convId }.forEach { NativeLib.dmRequestCallRoute(it.convId) } + } + } + + fun startDmCall() { + val dm = _currentDm.value ?: return + if (!_dmCallAvailable.value) { + LogManager.addLog("WARN", "Call", "PM call unavailable conv=${dm.convId}"); return + } + startCall("dm:${dm.convId}", dm.peerNodeId, dm.displayName) + } + fun refreshDmConversations() { if (repo != null) NativeLib.dmRequestConversations() } @@ -830,12 +880,15 @@ class ChatViewModel : ViewModel() { if (repo == null) return val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName) _currentDm.value = dm + _dmCallAvailable.value = false + refreshDmCallRoute() _dmMessages.value = emptyList() refreshDmMessages() } fun closeDm() { _currentDm.value = null + _dmCallAvailable.value = false _dmMessages.value = emptyList() } 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 53eca907..4afc902d 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 @@ -323,4 +323,7 @@ BGP: %1$d узлов BGP: %1$d узла + "Запускать после перезагрузки" + "Запускать включённый сервис после первой разблокировки телефона после перезагрузки." + "Не удалось сохранить настройку автозапуска" 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 d0a5ba50..5517e926 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -321,4 +321,7 @@ BGP: %1$d node BGP: %1$d nodes + "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 46666669..3a52c016 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1924,6 +1924,22 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmRequestConversa utun_bridge_dm_request_conversations(); } +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmRequestCallRoute( + JNIEnv* env, jobject thiz, jstring convId) { + (void)thiz; + struct UASYNC* ua = instance_lite_get_uasync(); + if (!uasync_is_running(ua)) { bridge_log(BLEV_WARN, "PM call route: core stopped"); return; } + const char* conv = (*env)->GetStringUTFChars(env, convId, NULL); + if (!conv) return; + struct dm_messages_req* req = u_calloc(1, sizeof(*req)); + if (req) { + req->inst = instance_lite_get_instance(); + snprintf(req->conv_id, sizeof(req->conv_id), "%s", conv); + uasync_post(ua, dm_call_route_trampoline, req); + } else bridge_log(BLEV_ERROR, "PM call route: allocation failed"); + (*env)->ReleaseStringUTFChars(env, convId, conv); +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmRequestMessages( JNIEnv* env, jobject thiz, jstring convId) { (void)thiz; diff --git a/tools/chatgui/src/channeldelegate.cpp b/tools/chatgui/src/channeldelegate.cpp index eeeea696..2f85b58e 100644 --- a/tools/chatgui/src/channeldelegate.cpp +++ b/tools/chatgui/src/channeldelegate.cpp @@ -48,6 +48,8 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio const int margin = 8; const int iconSize = 32; + bool isDm = index.data(ChannelChannelIdRole).toString().startsWith(QStringLiteral("dm:")); + bool dmOnline = index.data(ChannelDmOnlineRole).toBool(); int peersOnline = index.data(ChannelPeersOnlineRole).toInt(); int autoActive = index.data(ChannelAutoConnectActiveRole).toInt(); @@ -79,11 +81,11 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio } /* ── online count badge (top-left of avatar, above it) ── */ - if (peersOnline > 0) { + if (isDm || peersOnline > 0) { int badgeSize = 16; QRect badgeRect(iconRect.left() - 4, iconRect.top() - 6, badgeSize, badgeSize); painter->setRenderHint(QPainter::Antialiasing); - painter->setBrush(QColor("#4CAF50")); + painter->setBrush(QColor(isDm && !dmOnline ? "#E53935" : "#4CAF50")); painter->setPen(QPen(Qt::white, 1.5)); painter->drawEllipse(badgeRect); QFont badgeFont = option.font; @@ -93,7 +95,7 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio painter->setPen(Qt::white); QString num = QString::number(peersOnline); if (peersOnline > 9) num = "9+"; - painter->drawText(badgeRect, Qt::AlignCenter, num); + if (!isDm) painter->drawText(badgeRect, Qt::AlignCenter, num); } QRect textRect(iconRect.right() + margin, option.rect.top() + margin, diff --git a/tools/chatgui/src/channeldelegate.h b/tools/chatgui/src/channeldelegate.h index aacae16a..f4a1531f 100644 --- a/tools/chatgui/src/channeldelegate.h +++ b/tools/chatgui/src/channeldelegate.h @@ -14,6 +14,7 @@ enum ChannelDataRole { ChannelSettingsBtnHoveredRole = Qt::UserRole + 9, /* bool: hover for per-channel settings gear */ ChannelUnreadCountRole = Qt::UserRole + 6, /* int: unread message count, 0 if none */ ChannelLastMsgAtRole = Qt::UserRole + 7, /* qint64: last message timestamp for sorting */ + ChannelDmOnlineRole = Qt::UserRole + 11, /* bool: живой BGP-путь PM */ ChannelIsSectionRole = Qt::UserRole + 10 /* bool: 1 = заголовок секции (не выбирается) */ }; diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp index 91ebee7c..3d2164c2 100644 --- a/tools/chatgui/src/channellist.cpp +++ b/tools/chatgui/src/channellist.cpp @@ -11,6 +11,9 @@ extern "C" { #include "../../lib/debug_config.h" +#include "../../lib/mem.h" +#include "../transport/gui_bridge.h" +#include "dm/dm_core.h" } /* QListView that prevents deselection, handles invite plus-button clicks */ @@ -125,6 +128,20 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent) , m_model(new QStandardItemModel(this)) , m_db(db) { + m_dmPresenceTimer = new QTimer(this); + m_dmPresenceTimer->setSingleShot(true); + m_dmPresenceTimer->setInterval(50); + connect(m_dmPresenceTimer, &QTimer::timeout, this, [this]() { + auto* inst = gui_bridge_get_inst(); + if (!inst || !m_db) { clearDmPresence(); return; } + for (const auto& dm : m_db->getDmConversations()) { + auto* req = (struct dm_messages_req*)u_calloc(1, sizeof(struct dm_messages_req)); + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "PM presence: allocation failed"); return; } + req->inst = inst; + snprintf(req->conv_id, sizeof(req->conv_id), "%s", dm.convId.toUtf8().constData()); + gui_bridge_post_uasync_fn(dm_call_route_trampoline, req); + } + }); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); @@ -206,7 +223,40 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent) this, &ChannelList::channelSettingsRequested); } +void ChannelList::refreshDmPresence() { + if (!m_dmPresenceTimer->isActive()) m_dmPresenceTimer->start(); +} + +void ChannelList::clearDmPresence() { + m_dmPresenceTimer->stop(); + m_dmOnline.clear(); + for (int i = 0; i < m_model->rowCount(); i++) { + auto* item = m_model->item(i); + if (item->data(ChannelChannelIdRole).toString().startsWith(QStringLiteral("dm:"))) + item->setData(false, ChannelDmOnlineRole); + } +} + +void ChannelList::onDmCallRoute(const uint8_t* data, int len) { + if (!data || len < 17 || len != 17 + data[0]) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM presence snapshot: invalid size=%d", len); return; + } + QString conv = QString::fromUtf8((const char*)data + 1, data[0]); + quint64 peer = 0, group = 0; + memcpy(&peer, data + 1 + data[0], 8); memcpy(&group, data + 9 + data[0], 8); + bool online = peer && group; + if (m_dmOnline.value(conv) != online) + DEBUG_INFO(DEBUG_CATEGORY_CALL, "PM presence: conv=%s online=%d", conv.toUtf8().constData(), online); + m_dmOnline.insert(conv, online); + for (int i = 0; i < m_model->rowCount(); i++) { + auto* item = m_model->item(i); + if (item->data(ChannelChannelIdRole).toString() == QStringLiteral("dm:") + conv) + item->setData(online, ChannelDmOnlineRole); + } +} + void ChannelList::loadChannels() { + refreshDmPresence(); QString selectedCid; if (m_listView->currentIndex().isValid()) selectedCid = m_listView->currentIndex().data(ChannelChannelIdRole).toString(); @@ -258,6 +308,7 @@ void ChannelList::loadChannels() { auto *item = new QStandardItem(); item->setData(name, ChannelNameRole); item->setData(QStringLiteral("dm:") + dm.convId, ChannelChannelIdRole); + item->setData(m_dmOnline.value(dm.convId), ChannelDmOnlineRole); item->setData(QString(), ChannelLastMessageRole); item->setData(0, ChannelUnreadCountRole); item->setData(dm.lastTs, ChannelLastMsgAtRole); diff --git a/tools/chatgui/src/channellist.h b/tools/chatgui/src/channellist.h index f2fd5d36..efa832bd 100644 --- a/tools/chatgui/src/channellist.h +++ b/tools/chatgui/src/channellist.h @@ -4,6 +4,8 @@ #include #include #include +#include +#include class DbManager; @@ -13,6 +15,9 @@ public: explicit ChannelList(DbManager* db = nullptr, QWidget *parent = nullptr); void loadChannels(); + void refreshDmPresence(); + void onDmCallRoute(const uint8_t* data, int len); + void clearDmPresence(); void selectChannel(const QString& channelId); void setChannelPeersOnline(const QString& channelId, int count); void setAutoConnectActive(bool active); @@ -34,4 +39,6 @@ private: QStandardItemModel *m_model; DbManager *m_db; bool m_autoConnectActive = false; + QHash m_dmOnline; + QTimer* m_dmPresenceTimer; }; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 8e9113c0..1d7fb66f 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -168,6 +168,8 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath, MainWindow::~MainWindow() { s_mainWindow = nullptr; m_autoPlayTimer->stop(); + gui_bridge_set_network_changed_cb(nullptr); + gui_bridge_set_dm_call_route_cb(nullptr); gui_bridge_set_msg_received_cb(nullptr); gui_bridge_set_channel_updated_cb(nullptr); gui_bridge_set_member_list_cb(nullptr); @@ -488,6 +490,20 @@ void MainWindow::setupBridgeCallbacks() { if (s_mainWindow) s_mainWindow->onDmMsgReceived(QString::fromUtf8(conv_id, conv_id_len), author); }); + gui_bridge_set_network_changed_cb([](int type) { + if (!s_mainWindow) return; + if (type == 13) { + s_mainWindow->m_messageList->clearDmCallRoute(); + s_mainWindow->m_channelList->clearDmPresence(); + } else { + s_mainWindow->m_channelList->refreshDmPresence(); + } + }); + gui_bridge_set_dm_call_route_cb([](const uint8_t* data, int len) { + if (!s_mainWindow) return; + s_mainWindow->m_messageList->onDmCallRoute(data, len); + s_mainWindow->m_channelList->onDmCallRoute(data, len); + }); gui_bridge_set_dm_messages_cb([](const uint8_t* data, int len) { if (s_mainWindow) s_mainWindow->m_messageList->onDmMessages(data, len); @@ -527,6 +543,7 @@ void MainWindow::setupConnects() { connect(m_accountList, &AccountList::memberRenamed, m_messageList, &MessageList::reloadLocalNicks); connect(m_accountList, &AccountList::callRequested, this, &MainWindow::onCallRequested); + connect(m_messageList, &MessageList::callRequested, this, &MainWindow::onCallRequested); connect(m_accountList, &AccountList::pmRequested, this, &MainWindow::onPmRequested); connect(m_messageList, &MessageList::readPositionChanged, this, [this](const QString& channelId, qint64 lastReadMsgId) { @@ -542,11 +559,17 @@ void MainWindow::setupConnects() { /* ── звонки ── */ void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name) { + if (chId.startsWith(QStringLiteral("dm:")) && !m_activeCalls.isEmpty()) { + auto* w = m_activeCalls.constBegin().value(); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "PM call: existing call window id=%llu", (unsigned long long)w->callId()); + w->show(); w->raise(); w->activateWindow(); + return; + } quint64 callId = QRandomGenerator::global()->generate64(); if (!callId) callId = 1; struct call_start_arg* a = (struct call_start_arg*)u_malloc(sizeof(*a)); - if (!a) return; + if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "Call start: allocation failed"); return; } memset(a, 0, sizeof(*a)); a->inst = gui_bridge_get_inst(); strncpy(a->ch_id, chId.toUtf8().constData(), sizeof(a->ch_id) - 1); diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index f6d8e41a..874dad88 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -349,6 +349,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent hl->setContentsMargins(10, 6, 10, 6); hl->setSpacing(8); + m_peerStatus = new QLabel(header); + m_peerStatus->setFixedSize(12, 12); + m_peerStatus->hide(); + hl->addWidget(m_peerStatus); m_channelTitle = new QLabel(header); m_channelTitle->setStyleSheet("font-weight: bold; font-size: 14px;"); m_channelTitle->setText(QStringLiteral("Канал")); @@ -359,6 +363,18 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_talkingLabel->hide(); hl->addWidget(m_talkingLabel); + m_callBtn = new QPushButton(QStringLiteral("☎"), header); + m_callBtn->setAccessibleName(QStringLiteral("Позвонить")); + m_callBtn->setFixedSize(34, 30); + m_callBtn->setCursor(Qt::PointingHandCursor); + m_callBtn->hide(); + clearDmCallRoute(); + hl->addWidget(m_callBtn); + connect(m_callBtn, &QPushButton::clicked, this, [this]() { + if (m_callBtn->isEnabled() && m_callPeer && isDmChannel(m_currentChannelId)) + emit callRequested(m_currentChannelId, m_callPeer, m_channelTitle->text()); + }); + m_radioBtn = new QPushButton(header); m_radioBtn->setIcon(makeRadioIcon(header->palette().color(QPalette::ButtonText))); m_radioBtn->setIconSize(QSize(24, 24)); @@ -638,6 +654,10 @@ void MessageList::loadChannel(const QString& channelId) { m_model->clear(); m_avatarCache.clear(); m_currentChannelId = channelId; + clearDmCallRoute(); + m_callBtn->setVisible(isDmChannel(channelId)); + m_peerStatus->setVisible(isDmChannel(channelId)); + m_radioBtn->setVisible(!isDmChannel(channelId)); m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId); m_readDebounce->stop(); @@ -654,6 +674,17 @@ void MessageList::loadChannel(const QString& channelId) { /* История и состояния PM поступают из ядра в одном снимке. */ if (isDmChannel(channelId)) { m_radioBtn->setEnabled(false); + if (m_db) { + for (const auto& conv : m_db->getDmConversations()) { + if (conv.convId != dmConvId(channelId)) continue; + QString name = m_db->getDisplayName(conv.peerNodeId); + if (name.isEmpty()) name = conv.peerName; + if (name.isEmpty()) name = QStringLiteral("0x%1").arg(conv.peerNodeId, 16, 16, QChar('0')); + m_channelTitle->setText(name); + break; + } + } + refreshDmCallRoute(); requestDmMessages(channelId); if (prevFocus) prevFocus->setFocus(); @@ -1041,6 +1072,45 @@ void MessageList::onMessageContextMenu(const QPoint& pos) { /* ── DM (p2p чат) ── */ +void MessageList::clearDmCallRoute() { + m_callPeer = 0; m_callGroup = 0; + m_callBtn->setEnabled(false); + m_callBtn->setToolTip(QStringLiteral("Собеседник недоступен")); + m_peerStatus->setStyleSheet("background: #E53935; border-radius: 6px;"); + m_peerStatus->setToolTip(QStringLiteral("Не в сети")); +} + +void MessageList::refreshDmCallRoute() { + if (!isDmChannel(m_currentChannelId)) return; + auto* inst = gui_bridge_get_inst(); + if (!inst) { clearDmCallRoute(); return; } + auto* req = (struct dm_messages_req*)u_calloc(1, sizeof(struct dm_messages_req)); + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "PM route request: allocation failed"); clearDmCallRoute(); return; } + req->inst = inst; + QByteArray conv = dmConvId(m_currentChannelId).toUtf8(); + snprintf(req->conv_id, sizeof(req->conv_id), "%s", conv.constData()); + gui_bridge_post_uasync_fn(dm_call_route_trampoline, req); +} + +void MessageList::onDmCallRoute(const uint8_t* data, int len) { + if (!data || len < 17 || len != 17 + data[0]) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM route snapshot: invalid size=%d", len); return; + } + QString conv = QString::fromUtf8((const char*)data + 1, data[0]); + if (m_currentChannelId != QStringLiteral("dm:") + conv) return; + quint64 peer = 0, group = 0; + memcpy(&peer, data + 1 + data[0], 8); + memcpy(&group, data + 9 + data[0], 8); + if (m_callPeer != peer || m_callGroup != group) + DEBUG_INFO(DEBUG_CATEGORY_CALL, "PM button: conv=%s peer=%llu group=%llu enabled=%d", + conv.toUtf8().constData(), (unsigned long long)peer, (unsigned long long)group, peer && group); + m_callPeer = peer; m_callGroup = group; + m_callBtn->setEnabled(peer && group); + m_peerStatus->setStyleSheet(group ? "background: #4CAF50; border-radius: 6px;" : "background: #E53935; border-radius: 6px;"); + m_peerStatus->setToolTip(group ? QStringLiteral("В сети") : QStringLiteral("Не в сети")); + m_callBtn->setToolTip(group ? QStringLiteral("Позвонить") : QStringLiteral("Собеседник недоступен")); +} + bool MessageList::isDmChannel(const QString& channelId) { return channelId.startsWith(QStringLiteral("dm:")); } diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index 151f42d4..7d089826 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -30,11 +30,15 @@ public: void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks); void onAttachmentDownloaded(const QString& chId, int64_t msgId); void onDmMessages(const uint8_t* data, int len); + void refreshDmCallRoute(); + void onDmCallRoute(const uint8_t* data, int len); + void clearDmCallRoute(); void onAttachmentPreparation(const uint8_t* data, int len); void onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on); /* индикация говорящего */ signals: void readPositionChanged(const QString& channelId, qint64 lastReadMsgId); + void callRequested(const QString& target, quint64 peer, const QString& name); void openVideoRequested(const QString& filePath); void openImageRequested(const QString& filePath); @@ -56,8 +60,12 @@ private: bool m_wasAtBottom = false; QMap m_localNicks; AudioRecorder* m_recorder = nullptr; + QLabel* m_peerStatus = nullptr; QLabel* m_channelTitle = nullptr; /* шапка: название канала */ QLabel* m_talkingLabel = nullptr; /* шапка: кто говорит в рации */ + QPushButton* m_callBtn = nullptr; + quint64 m_callPeer = 0; + quint64 m_callGroup = 0; QPushButton* m_radioBtn = nullptr; /* шапка: переключатель рации */ QPushButton* m_pttBtn = nullptr; /* плавающая кнопка PTT поверх чата */ bool m_radioOn = false; diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 08b9f79f..c73bdb33 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -219,6 +219,11 @@ typedef void (*gui_dm_msg_received_fn)(const char* conv_id, int conv_id_len, uin void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb); /* Callback: DM-беседа создана/изменена (вызывается из GUI-потока). */ +/* Снимок BGP-доступности PM и уведомление об изменениях сети (GUI-поток). */ +typedef void (*gui_network_changed_fn)(int event_type); +void gui_bridge_set_network_changed_cb(gui_network_changed_fn cb); +void gui_bridge_set_dm_call_route_cb(void (*cb)(const uint8_t* data, int len)); + typedef void (*gui_dm_conv_updated_fn)(const char* conv_id, int conv_id_len); void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index e910822c..2fa2fdef 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -63,6 +63,8 @@ static gui_dm_msg_received_fn g_dm_msg_received_cb = nullptr; static gui_dm_conv_updated_fn g_dm_conv_updated_cb = nullptr; static gui_dm_messages_fn g_dm_messages_cb = nullptr; static gui_dm_messages_fn g_attachment_preparation_cb = nullptr; +static gui_dm_messages_fn g_dm_call_route_cb = nullptr; +static gui_network_changed_fn g_network_changed_cb = nullptr; static struct UASYNC* g_ua = nullptr; static struct UTUN_INSTANCE* g_inst = nullptr; @@ -72,7 +74,13 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { const uint8_t* d = (const uint8_t*)data.constData(); int dlen = data.size(); + if (g_network_changed_cb && (eventType == 4 || eventType == 5 || eventType == 12 || eventType == 13 || + eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33)) + g_network_changed_cb(eventType); switch (eventType) { + case CHAT_EVT_DM_CALL_ROUTE: + if (g_dm_call_route_cb) g_dm_call_route_cb(d, dlen); + break; case GUI_EVT_MSG_RECEIVED: if (dlen >= 2) { uint8_t chLen = d[0]; @@ -539,6 +547,9 @@ void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb) { g_dm_msg_received_cb = cb; } +void gui_bridge_set_network_changed_cb(gui_network_changed_fn cb) { g_network_changed_cb = cb; } +void gui_bridge_set_dm_call_route_cb(gui_dm_messages_fn cb) { g_dm_call_route_cb = cb; } + void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) { g_dm_conv_updated_cb = cb; }