android: voice messages + attachments architecture plan, build fixes

This commit is contained in:
evgeny committed 2026-07-28 23:07:23 +03:00
1 parent f8d7c21a79
commit e751e6ee69
17 files changed
+299 -329

No files matched your search

+98 -5
View File
@@ -71,16 +71,64 @@ tools/chatgui-android/
| Сборка C | CMake 3.16+ | GCC (Linux) / Clang (Android NDK) |
| Сборка APK | Gradle 8.x | AGP 8.x, Kotlin 1.9 |
## Сборка
## Зависимости
### Headless (Linux, для отладки сетевого стека)
### Для сборки APK (Android)
| Компонент | Версия | Установка |
|-----------|--------|-----------|
| JDK | 17+ | `apt install openjdk-17-jdk` |
| CMake | 3.22+ | `apt install cmake` |
| Android SDK | API 36 | через [sdkmanager](https://developer.android.com/studio#command-line-tools-only) или Android Studio |
| Android NDK | 29.0.14206865 | `sdkmanager "ndk;29.0.14206865"` |
| Gradle | 8.5 (wrapper) | `./gradlew` скачает сам |
| Kotlin | 1.9.20 | встроен в Gradle плагин |
| adb | platform-tools | `apt install adb` (для прошивки) |
OpenSSL для APK **предсобран** под `arm64-v8a` и `x86_64`, лежит в `app/src/main/cpp/openssl/`.
Собирать отдельно не нужно.
### Для Headless (Linux, отладка без телефона)
| Компонент | Установка |
|-----------|-----------|
| CMake 3.16+ | `apt install cmake` |
| GCC / Clang | `apt install build-essential` |
| OpenSSL dev | `apt install libssl-dev` |
| pthread | встроен в libc |
### Настройка окружения
Создать `local.properties` в корне `tools/chatgui-android/`:
```properties
sdk.dir=/home/user/Android/Sdk
```
Путь `ndk.dir` можно не указывать — Gradle скачает NDK сам (версия из `ndkVersion` в `app/build.gradle.kts`).
Если NDK уже установлен отдельно:
```properties
ndk.dir=/home/user/Android/Sdk/ndk/29.0.14206865
```
### Установка Android SDK (без Android Studio)
```bash
cd tools/chatgui-android && mkdir -p build && cd build
cmake .. && make -j4
./headless/headless
cd /tmp && wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip commandlinetools-linux-*.zip -d ~/Android
mkdir -p ~/Android/Sdk/cmdline-tools
mv ~/Android/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
export ANDROID_HOME=~/Android/Sdk
export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$PATH
yes | sdkmanager --licenses
sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;29.0.14206865"
```
## Сборка
### APK (Android)
```bash
@@ -90,6 +138,14 @@ export ANDROID_HOME=/home/user/Android/Sdk
# APK: app/build/outputs/apk/debug/app-debug.apk
```
### Headless (Linux, для отладки сетевого стека)
```bash
cd tools/chatgui-android && mkdir -p build && cd build
cmake .. && make -j4
./headless/headless
```
## Прошивка на телефон
```bash
@@ -109,6 +165,43 @@ nc localhost 9999
> {"id":2,"cmd":"join","link":"utun://..."}
```
## Конфиг
Конфиг хранится в Android DataStore (`ConfigProvider.kt`) и передаётся в C-ядро
при старте как INI-текст через `nativeStart(configText)`.
Формат (генерируется автоматически, пользователь не редактирует):
```ini
[global]
my_node_name=MyDevice
my_public_key=<64 hex chars X25519>
my_private_key=<64 hex chars X25519>
db_path=/data/data/com.utun.chat/files
db_sync_enabled=1
[server:main]
addr=0.0.0.0:<listen_port>
[allowed_keys]
allow_all=yes
[ntp]
enabled=no
[debug]
console_level=info
# Опционально — UDP-лог
[log_udp]
ip=<ip хоста>
port=9999
```
Ключи (`my_public_key`, `my_private_key`) генерируются автоматически при первом запуске
и сохраняются в `keys.conf` в `db_path`. Имя узла (`my_node_name`) берётся из
настроек DataStore или `Build.MODEL`.
## Логирование через logreceiver (только Android)
Debug-сообщения с телефона дублируются через UDP на приёмник `tools/logreceiver/`.
@@ -16,6 +16,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -19,6 +20,7 @@ fun MessageBubble(message: Message) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
@@ -26,7 +28,7 @@ fun MessageBubble(message: Message) {
) {
Column(
modifier = Modifier
.widthIn(max = 300.dp)
.widthIn(min = minBubbleWidth, max = 300.dp)
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.padding(12.dp)
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.QrCode
@@ -15,8 +16,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
@@ -72,26 +75,48 @@ fun ChannelListScreen(
} else {
LazyColumn {
items(channels) { ch ->
val barColor = if (ch.peersOnline > 0) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
val badgeText = when {
ch.peersOnline > 99 -> "99+"
ch.peersOnline > 0 -> ch.peersOnline.toString()
else -> ""
}
Surface(modifier = Modifier.clickable { onSelectChannel(ch) }) {
Row(Modifier.fillMaxWidth().padding(16.dp)) {
Box(Modifier.size(48.dp), contentAlignment = androidx.compose.ui.Alignment.Center) {
Surface(shape = MaterialTheme.shapes.small) {
Text("#", fontSize = 24.sp, modifier = Modifier.padding(8.dp))
}
}
Spacer(Modifier.width(12.dp))
Column {
Row {
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
if (ch.peersOnline > 0) {
Text(" (${ch.peersOnline})",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp))
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
Box(Modifier.width(4.dp).fillMaxHeight().background(barColor))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
Surface(shape = MaterialTheme.shapes.small) {
Text("#", fontSize = 22.sp, modifier = Modifier.padding(8.dp))
}
if (badgeText.isNotEmpty()) {
val badgeSize = 18.dp
Box(
Modifier
.align(Alignment.TopStart)
.offset(x = (-4).dp, y = (-6).dp)
.size(badgeSize)
.clip(CircleShape)
.background(Color(0xFF4CAF50)),
contentAlignment = Alignment.Center
) {
Text(
badgeText, fontSize = 9.sp, color = Color.White,
fontWeight = FontWeight.Bold, textAlign = TextAlign.Center
)
}
}
}
if (ch.lastMsg.isNotEmpty())
Text(ch.lastMsg, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
Spacer(Modifier.width(10.dp))
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
if (ch.lastMsg.isNotEmpty())
Text(ch.lastMsg, fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
}
}
@@ -5,7 +5,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -31,30 +31,17 @@ fun ChatScreen(
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(channel.name) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") }
}
)
},
bottomBar = {
InputBar(
text = text,
onTextChange = { text = it },
onSend = {
onSendMessage(text)
text = ""
},
enabled = true,
modifier = Modifier.navigationBarsPadding()
)
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().imePadding()
) {
TopAppBar(
title = { Text(channel.name) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
}
)
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).imePadding(),
modifier = Modifier.weight(1f).fillMaxWidth(),
state = listState
) {
items(messages.size) { idx ->
@@ -62,5 +49,15 @@ fun ChatScreen(
}
item { Spacer(Modifier.height(8.dp)) }
}
InputBar(
text = text,
onTextChange = { text = it },
onSend = {
onSendMessage(text)
text = ""
},
enabled = true,
modifier = Modifier.navigationBarsPadding()
)
}
}
@@ -15,6 +15,7 @@
#include "../../../lib/u_async.h"
#include "../../../lib/mem.h"
#include "../../../lib/sqlite3.h"
#include "../../../src/chat/chat_core.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -168,6 +169,24 @@ void utun_bridge_poll(void) {
void utun_bridge_send_message(const char* channel_id, const char* text) {
bridge_log(BLEV_DEBUG, "send ch=%s text=%s", channel_id ? channel_id : "", text ? text : "");
if (!channel_id || !text) return;
struct UASYNC* ua = instance_lite_get_uasync();
if (!ua) { bridge_log(BLEV_ERROR, "send: ua not ready"); return; }
size_t datalen = strlen(text);
struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + datalen);
if (!req) return;
strncpy(req->channel_id, channel_id, sizeof(req->channel_id) - 1);
strncpy(req->content_type, "text/plain", sizeof(req->content_type) - 1);
req->data = (uint8_t*)(req + 1);
req->data_len = (uint32_t)datalen;
memcpy(req->data, text, datalen);
req->timestamp = 0;
uasync_post(ua, chat_core_submit_trampoline, req);
bridge_log(BLEV_DEBUG, "send queued ch=%s len=%zu", channel_id, datalen);
}
void utun_bridge_create_channel(const char* name, const char* channel_id) {
@@ -232,6 +251,19 @@ static char* json_escape_alloc(const char* str) {
return out;
}
static void bridge_get_node_name(sqlite3* db, uint64_t node_id, char* out, size_t sz) {
out[0] = '\0';
if (!db || node_id == 0) return;
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, "SELECT name FROM nodes WHERE node_id=?", -1, &st, NULL) != SQLITE_OK) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
if (sqlite3_step(st) == SQLITE_ROW) {
const char* n = (const char*)sqlite3_column_text(st, 0);
if (n && n[0]) snprintf(out, sz, "%s", n);
}
sqlite3_finalize(st);
}
char* utun_bridge_get_channels_json(void) {
sqlite3* db = chat_core_get_db();
if (!chat_core_is_initialized() || !db) {
@@ -274,7 +306,11 @@ char* utun_bridge_get_channels_json(void) {
const char* ds = strstr(dp, "\"d\":\"");
if (ds) { ds += 5; char* de = strchr((char*)ds, '"'); if (de) { size_t tl = (size_t)(de - ds); if (tl > 120) tl = 120; memcpy(txt, ds, tl); txt[tl] = '\0'; } }
}
snprintf(last_msg, sizeof(last_msg), "0x%016llx: %s", (unsigned long long)author, txt);
char author_name[128];
bridge_get_node_name(db, (uint64_t)author, author_name, sizeof(author_name));
if (!author_name[0])
snprintf(author_name, sizeof(author_name), "0x%016llx", (unsigned long long)author);
snprintf(last_msg, sizeof(last_msg), "%s: %s", author_name, txt);
}
sqlite3_finalize(lm);
}
@@ -344,12 +380,17 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) {
const char* sep = first ? "" : ",";
first = 0;
size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"0x%016llx\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}",
sep, (long long)msg_id, (unsigned long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false");
char author_name[128];
bridge_get_node_name(db, (uint64_t)node_id, author_name, sizeof(author_name));
if (!author_name[0])
snprintf(author_name, sizeof(author_name), "0x%016llx", (unsigned long long)node_id);
size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}",
sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false");
while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; }
if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"0x%016llx\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}",
sep, (long long)msg_id, (unsigned long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false");
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}",
sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false");
u_free(esc_txt);
}
sqlite3_finalize(st);
@@ -198,6 +198,12 @@ static void* instance_thread(void* arg) {
/* Bind chat sync via etcp_router */
etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL);
/* Set my_name from config */
if (g_inst->config->global.name[0]) {
chat_core_update_my_name(g_inst->config->global.name);
IL_LOGI("my_name set to '%s'", g_inst->config->global.name);
}
IL_LOGI("all init done, entering poll loop");
/* Notify GUI of current public key (initial generation or existing) */
@@ -331,6 +337,11 @@ static void restart_trampoline(void* arg) {
return;
}
if (g_inst->config->global.name[0]) {
chat_core_update_my_name(g_inst->config->global.name);
IL_LOGI("restart: my_name set to '%s'", g_inst->config->global.name);
}
IL_LOGI("restart: done");
}