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; }