android: voice messages + attachments architecture plan, build fixes
This commit is contained in:
1 parent
f8d7c21a79
commit
e751e6ee69
17 files changed
+299
-329
No files matched your search
@@ -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" />
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+42
-17
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
extern "C" {
|
||||
#include "secure_channel.h"
|
||||
#include "../../../lib/sha256.h"
|
||||
}
|
||||
|
||||
JoinDialog::JoinDialog(UtunNode* node, DbManager* db, QWidget* parent,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// node_config.cpp — Simplified INI config manager for embedded uTun node
|
||||
#include "node_config.h"
|
||||
#include "config_updater.h"
|
||||
#include <QHostInfo>
|
||||
|
||||
extern "C" {
|
||||
#include <openssl/evp.h>
|
||||
@@ -165,7 +166,12 @@ void NodeConfig::generateIdentity() {
|
||||
}
|
||||
|
||||
bool NodeConfig::create(const QString& nodeName, quint16 listenPort) {
|
||||
m_nodeName = nodeName.isEmpty() ? "chatgui" : nodeName;
|
||||
if (nodeName.isEmpty()) {
|
||||
QString host = QHostInfo::localHostName();
|
||||
m_nodeName = host.isEmpty() ? "chatgui" : host;
|
||||
} else {
|
||||
m_nodeName = nodeName;
|
||||
}
|
||||
if (m_pubKey.isEmpty()) generateIdentity();
|
||||
if (m_controlAddr.isEmpty())
|
||||
m_controlAddr = "127.0.0.1:9999";
|
||||
|
||||
Reference in new issue
Block a user