Fix BLE link display and remove obsolete tool projects

This commit is contained in:
evgeny committed 2026-10-29 17:10:39 +01:00
1 parent f84622966e
commit 5152b17114
68 files changed
+300 -3885

No files matched your search

+6
View File
@@ -54,6 +54,12 @@ RX использует ll_queue общего транспорта и включ
кадры — неизменённые зашифрованные ETCP-пакеты, без IP-заголовков/добавочного framing.
До завершения обмена ключами действует дедлайн 20 секунд.
Диагностика линка в обеих GUI использует общий etcp_link_get_snapshot: подпись BLE,
физическое направление из ble_get_outgoing и фактические link_state/link_status
ETCP. GATT READY сам по себе не включает зелёный индикатор. Адреса IP, порты и имя
сетевого сокета для BLE отсутствуют. До прикрепления к ETCP попытка GATT не входит
в список ETCP-линков; протокольный is_server не подменяется направлением GATT.
BLE callbacks только обновляют состояние/будят отложенную обработку. Закрытие
GATT делается вне этих callbacks; packet_link откладывает destroy. При выключении
frontend закрывает свои ETCP-линки, отсоединяет ctx от manager и уничтожает радио;
+10
View File
@@ -14,6 +14,10 @@ link = etcp_link_add(conn, &driver); // проверить рез
Драйвер создаёт вызывающий модуль. В packet_link_driver передаются ctx, ua,
диагностическое name и функции bind/get_state/send/set_rx_enabled/stop/destroy.
direction задаёт физическое направление (UNKNOWN/OUTGOING/INCOMING), копируется
при bind и доступно через packet_link_get_direction. Оно не задаёт роль handshake
верхнего протокола. name служит также подписью транспорта в диагностике;
по его содержимому не выбираются кодирование или способ доставки.
Один ctx обслуживает один двусторонний канал к пиру. Общий UDP-сокет или BLE-радио
может принадлежать отдельному владельцу и обслуживать несколько экземпляров.
Несколько разных драйверов добавляются в один conn отдельными вызовами link_add.
@@ -69,6 +73,12 @@ ETCP_LINK владеет привязкой io. packet_link из get заимс
Общий путь отправки/приёма вызывает кодирование через etcp_link_codec и доставку
через API экземпляра драйвера. Кодирование не выбирается по имени драйвера.
etcp_link_get_snapshot копирует описание линка в uasync без callbacks и ссылок
на ресурсы драйвера: подпись, физическое направление, состояние ETCP, IP-адреса
и имя сокета, если они есть. Внешний пакетный канал имеет has_ip=0, имя/направление
берутся из packet_link. IP-путь сохраняет подписи UDP/TCP/TCP+REALITY и свои адреса.
Desktop MEMBER_DETAIL и Android JNI JSON сериализуют один и тот же снимок.
etcp_link_add использует защищённые датаграммы ETCP без IP-заголовков. Ключ пира
уже задан в conn. Приём привязан к каналу, поэтому адресный bootstrap, NAT и поиск
сокета не нужны. INIT и согласование эпох используют существующие форматы ETCP.
+6
View File
@@ -51,6 +51,12 @@ static struct ble_connection* lookup(struct ble_transport* t, ble_link_id id) {
if (t && id) for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id == id) return &t->links[i];
return NULL;
}
int ble_get_outgoing(struct ble_transport* t, ble_link_id id, int* outgoing) {
struct ble_connection* c = lookup(t, id);
if (!c || !outgoing) { DEBUG_ERROR(CAT, "BLE direction: invalid link=%llu/output", (unsigned long long)id); return -1; }
*outgoing = c->outgoing;
return 0;
}
static void close_link(struct ble_transport* t, struct ble_connection* c, const char* reason) {
ble_link_id id = c->id;
uint64_t peer = c->peer;
+2
View File
@@ -59,6 +59,8 @@ int ble_advertise(struct ble_transport* transport, const void* data, size_t len)
/* Самостоятельно выполнить connect/discovery/subscribe/HELLO. expected_node=0 не проверяет ID ответа.
* Handle либо 0 при отказе. Готовность/ошибка — connection; физическое ACL может обслуживать другие сервисы. */
ble_link_id ble_connect(struct ble_transport* transport, const struct ble_endpoint* endpoint, uint64_t expected_node);
/* Копировать направление существующего GATT в outgoing (0/1). 0 — успех, -1 — неверный handle/аргументы с логом. */
int ble_get_outgoing(struct ble_transport* transport, ble_link_id link, int* outgoing);
void ble_disconnect(struct ble_transport* transport, ble_link_id link);
/* Принять целый ll_entry в TX. При 0 владение передано; при любом отказе entry остаётся вызывающему.
* entry должен быть вне очереди, dgram!=NULL, len=1..1500. Заполнение: WOULD_BLOCK, затем writable. */
+8 -3
View File
@@ -58,7 +58,8 @@ static void driver_received(void* arg, struct ll_entry* packet);
struct packet_link* packet_link_bind(const struct packet_link_driver* driver,
const struct packet_link_callbacks* callbacks, void* arg) {
if (!driver || !driver->ua || !driver->name || !driver->name[0] || !driver->bind || !driver->get_state ||
!driver->send || !driver->stop || !driver->destroy || !driver->set_rx_enabled || !callbacks || !callbacks->received) {
!driver->send || !driver->stop || !driver->destroy || !driver->set_rx_enabled || !callbacks || !callbacks->received ||
driver->direction < PACKET_LINK_DIRECTION_UNKNOWN || driver->direction > PACKET_LINK_INCOMING) {
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link: invalid driver API"); return NULL;
}
struct packet_link_state initial = driver->get_state(driver->ctx);
@@ -78,13 +79,17 @@ struct packet_link* packet_link_bind(const struct packet_link_driver* driver,
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "packet link %s: driver binding rejected", link->name);
uasync_call_soon_cancel(link->ua, link->notification); u_free(link->name); u_free(link); return NULL;
}
DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "packet link=%p driver=%s phase=%d max=%zu ready=%d",
(void*)link, link->name, initial.phase, initial.max_packet, initial.tx_ready);
DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "packet link=%p driver=%s direction=%d phase=%d max=%zu ready=%d",
(void*)link, link->name, link->driver.direction, initial.phase, initial.max_packet, initial.tx_ready);
return link;
}
const char* packet_link_name(const struct packet_link* link) { return link ? link->name : "unbound"; }
enum packet_link_direction packet_link_get_direction(const struct packet_link* link) {
return link ? link->driver.direction : PACKET_LINK_DIRECTION_UNKNOWN;
}
struct packet_link_state packet_link_get_state(const struct packet_link* link) {
if (!link || link->closing) return (struct packet_link_state){PACKET_LINK_TERMINATED, 0, 0, PACKET_LINK_CLOSED};
return link->state;
+4
View File
@@ -15,6 +15,8 @@ enum packet_link_result {
PACKET_LINK_TOO_LARGE = -3, PACKET_LINK_INVALID = -4, PACKET_LINK_ERROR = -5
};
enum packet_link_phase { PACKET_LINK_CONNECTING, PACKET_LINK_OPEN, PACKET_LINK_TERMINATED };
/* Направление физического подключения; не роль протокола, передаваемого по каналу. */
enum packet_link_direction { PACKET_LINK_DIRECTION_UNKNOWN, PACKET_LINK_OUTGOING, PACKET_LINK_INCOMING };
struct packet_link_state {
enum packet_link_phase phase; /* OPEN — готов транспорт, не ETCP-сессия. */
size_t max_packet; /* Максимальный пакет, включая байты формата пользователя. */
@@ -27,6 +29,8 @@ struct packet_link_callbacks {
};
/* Снимок без callbacks/изменения очередей; NULL возвращает TERMINATED/CLOSED. */
struct packet_link_state packet_link_get_state(const struct packet_link* link);
/* Неизменное направление из скопированного описания драйвера; NULL возвращает UNKNOWN. */
enum packet_link_direction packet_link_get_direction(const struct packet_link* link);
/* Целый извлечённый из очереди entry: OK передаёт владение, любой отказ оставляет его вызывающему. */
int packet_link_send(struct packet_link* link, struct ll_entry* packet);
/* Включить/приостановить received; нижний модуль сам ограничивает накопление. Изначально RX выключен. */
+1
View File
@@ -23,6 +23,7 @@ struct packet_link_driver {
void (*set_rx_enabled)(void* ctx, int enabled); /* Разрешить/приостановить RX; накоплением управляет драйвер. */
void (*stop)(void* ctx); /* Сразу запретить события, остановить канал; не освобождать ctx. */
void (*destroy)(void* ctx); /* Освободить остановленный ctx после выхода обработчиков. */
enum packet_link_direction direction; /* Диагностика подключения; копируется при bind, 0 — неизвестно. */
};
/* Копирует API/name/callbacks. Успех передаёт ctx оболочке; отказ сохраняет его вызывающему.
* arg живёт до close. Первое state_changed отложено. TERMINATED необратим, max_packet не меняется. */
+4 -4
View File
@@ -128,19 +128,19 @@ struct chat_member_detail_req {
};
/* Wire LE: request:8, group:8, node:8, result:1, transport_flags:1, own_tcp:2,
* hop_count:1, link_count:1, sock_count:1; далее hops:8*, links:64*, socks:48*.
* hop_count:1, link_count:1, sock_count:1; далее hops:8*, links:94*, socks:48*.
* transport_flags: bit0=conn_present, bit1=links_up, bit2=initialized; не доступность в группе.
* hops — список выбранного пути от назначения к первому посреднику; у offline/self пустой.
* Ошибка содержит только заголовок;INVALID допускает нулевые поля неверного исходного запроса.
* Счётчики own_tcp и ссылок сокета насыщаются на65535;списки ограничены16 элементами каждый.
* link: tcp:1,state:1,status:1,ready:1,server:1,local_family:1,local_addr:16,local_port:2,
* remote_family:1,remote_addr:16,remote_port:2,socket_name:20,reality:1.
* link: direction:1 (0=unknown,1=out,2=in),state:1,status:1,has_ip:1,local_family:1,local_addr:16,local_port:2,
* remote_family:1,remote_addr:16,remote_port:2,socket_name:20,protocol:32. Не-IP не имеет адресов/портов.
* sock: id:4,port:2,links_up:2,links_down:2,ifname:38. Строки завершаются NUL. */
#define CHAT_MEMBER_DETAIL_HEADER_SIZE 31
#define CHAT_MEMBER_DETAIL_MAX_HOPS 16
#define CHAT_MEMBER_DETAIL_MAX_LINKS 16
#define CHAT_MEMBER_DETAIL_MAX_SOCKS 16
#define CHAT_MEMBER_DETAIL_LINK_SIZE 64
#define CHAT_MEMBER_DETAIL_LINK_SIZE 94
#define CHAT_MEMBER_DETAIL_SOCK_SIZE 48
#define CHAT_MEMBER_DETAIL_MAX_SIZE (CHAT_MEMBER_DETAIL_HEADER_SIZE + 8 * CHAT_MEMBER_DETAIL_MAX_HOPS + \
CHAT_MEMBER_DETAIL_LINK_SIZE * CHAT_MEMBER_DETAIL_MAX_LINKS + CHAT_MEMBER_DETAIL_SOCK_SIZE * CHAT_MEMBER_DETAIL_MAX_SOCKS)
+20 -41
View File
@@ -19,6 +19,7 @@
#include "../transport_layer/etcp.h"
#include "../transport_layer/etcp_connections.h"
#include "../transport_layer/stcp_link.h"
#include "../transport_layer/etcp_link_io.h"
#include "../transport_layer/pkt_normalizer.h"
#include "../routing_layer/topo_node.h"
#include "../routing_layer/topo_node_sqlite.h"
@@ -381,6 +382,9 @@ void chat_core_collect_conn_metrics_trampoline(void* arg) {
#if CHAT_MEMBER_DETAIL_MAX_HOPS != MAX_HOPS
#error "member detail hop limit must match routing"
#endif
#if CHAT_MEMBER_DETAIL_LINK_SIZE != 42 + 20 + ETCP_LINK_PROTOCOL_SIZE
#error "member detail link size must match the serialized snapshot"
#endif
/* Проверяем размеры, адресата и все строки до изменения состояния GUI. */
int chat_core_parse_member_detail(const uint8_t* data, size_t len, struct chat_member_detail_view* out) {
@@ -410,8 +414,10 @@ int chat_core_parse_member_detail(const uint8_t* data, size_t len, struct chat_m
}
for (int i = 0; i < v.link_count; i++) {
const uint8_t* link = v.links + i * CHAT_MEMBER_DETAIL_LINK_SIZE;
if (!memchr(link + 43, 0, 20) || link[0] > 1 || link[3] > 1 || link[4] > 1 || link[63] > 1 ||
(link[5] && link[5] != 4 && link[5] != 6) || (link[24] && link[24] != 4 && link[24] != 6)) {
if (!memchr(link + 42, 0, 20) || !link[62] || !memchr(link + 62, 0, 32) ||
link[0] > PACKET_LINK_INCOMING || link[3] > 1 ||
(link[4] && link[4] != 4 && link[4] != 6) || (link[23] && link[23] != 4 && link[23] != 6) ||
(!link[3] && (link[4] || link[21] || link[22] || link[23] || link[40] || link[41]))) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: invalid link index=%d", i); return -1;
}
}
@@ -519,46 +525,19 @@ static void collect_member_detail(const struct chat_member_detail_req* req) {
for (int i = 0; i < hop_count; i++, p += 8) mf_put64(p, hops[i]);
for (struct ETCP_LINK* lk = conn ? conn->links : NULL; lk && link_count; lk = lk->next, link_count--) {
*p++ = lk->is_tcp ? 1 : 0;
*p++ = lk->link_state;
*p++ = lk->link_status;
*p++ = (lk->is_tcp && lk->tcp_link && stcp_link_is_ready(lk->tcp_link)) ? 1 : 0;
*p++ = lk->is_server;
{ uint8_t lf = 0; uint8_t la[16] = {0}; uint16_t lp = 0;
if (lk->conn) {
const struct sockaddr_storage* lsa = lk->conn->interface_addr.ss_family ? &lk->conn->interface_addr : &lk->conn->local_addr;
if (lsa->ss_family == AF_INET) {
lf = 4; memcpy(la, &((const struct sockaddr_in*)lsa)->sin_addr, 4); lp = ntohs(((const struct sockaddr_in*)lsa)->sin_port);
} else if (lsa->ss_family == AF_INET6) {
lf = 6; memcpy(la, &((const struct sockaddr_in6*)lsa)->sin6_addr, 16); lp = ntohs(((const struct sockaddr_in6*)lsa)->sin6_port);
}
} else if (lk->is_tcp && lk->tcp_link) {
const struct sockaddr_storage* lsa = stcp_link_get_local_addr(lk->tcp_link);
if (lsa && lsa->ss_family == AF_INET) {
lf = 4; memcpy(la, &((const struct sockaddr_in*)lsa)->sin_addr, 4); lp = ntohs(((const struct sockaddr_in*)lsa)->sin_port);
} else if (lsa && lsa->ss_family == AF_INET6) {
lf = 6; memcpy(la, &((const struct sockaddr_in6*)lsa)->sin6_addr, 16); lp = ntohs(((const struct sockaddr_in6*)lsa)->sin6_port);
}
}
*p++ = lf; memcpy(p, la, 16); p += 16; *p++ = (uint8_t)lp; *p++ = (uint8_t)(lp >> 8);
struct etcp_link_snapshot snapshot;
if (etcp_link_get_snapshot(lk, &snapshot)) {
buf[24] = CHAT_MEMBER_DETAIL_ERROR;
memset(buf + 25, 0, CHAT_MEMBER_DETAIL_HEADER_SIZE - 25);
chat_event_post(inst, CHAT_EVT_MEMBER_DETAIL, buf, CHAT_MEMBER_DETAIL_HEADER_SIZE); return;
}
{ uint8_t rf = 0; uint8_t ra[16] = {0}; uint16_t rp = 0;
const struct sockaddr_storage* sa = &lk->remote_addr;
if (sa->ss_family == AF_INET) {
rf = 4; memcpy(ra, &((const struct sockaddr_in*)sa)->sin_addr, 4); rp = ntohs(((const struct sockaddr_in*)sa)->sin_port);
} else if (sa->ss_family == AF_INET6) {
rf = 6; memcpy(ra, &((const struct sockaddr_in6*)sa)->sin6_addr, 16); rp = ntohs(((const struct sockaddr_in6*)sa)->sin6_port);
}
*p++ = rf; memcpy(p, ra, 16); p += 16; *p++ = (uint8_t)rp; *p++ = (uint8_t)(rp >> 8);
}
{ char sn[20] = {0};
if (lk->conn) strncpy(sn, lk->conn->name, 19);
memcpy(p, sn, 20); p += 20;
}
*p++ = lk->reality_set ? 1 : 0;
*p++ = snapshot.direction; *p++ = snapshot.state; *p++ = snapshot.status; *p++ = snapshot.has_ip;
*p++ = snapshot.local.family; memcpy(p, snapshot.local.bytes, 16); p += 16;
*p++ = (uint8_t)snapshot.local.port; *p++ = (uint8_t)(snapshot.local.port >> 8);
*p++ = snapshot.remote.family; memcpy(p, snapshot.remote.bytes, 16); p += 16;
*p++ = (uint8_t)snapshot.remote.port; *p++ = (uint8_t)(snapshot.remote.port >> 8);
memcpy(p, snapshot.socket_name, 20); p[19] = 0; p += 20;
memcpy(p, snapshot.protocol, sizeof(snapshot.protocol)); p += sizeof(snapshot.protocol);
}
for (struct ETCP_SOCKET* s = inst->etcp_sockets; s && sock_count; s = s->next, sock_count--) {
+1 -1
View File
@@ -1192,7 +1192,7 @@ static struct ETCP_LINK* link_create(struct ETCP_CONN* etcp, struct ETCP_SOCKET*
etcp_link_update_inflight_lim(link, link->bbr->init_cwnd * etcp->normalizer->frag_size);
DEBUG_INFO(DEBUG_CATEGORY_ETCP, "NEW link etcp=[%s] link=%p socket=%s id=%u server=%u mtu=%u tcp=%u DATA_MSS=%u cwnd=%u",
etcp->log_name, link, conn ? conn->name : "tcp", link->local_link_id, link->is_server, link->mtu, link->is_tcp,
etcp->log_name, link, driver ? driver->name : conn ? conn->name : "tcp", link->local_link_id, link->is_server, link->mtu, link->is_tcp,
etcp->normalizer->frag_size, link->inflight_lim_bytes);
if (driver) return link; /* Событие packet_link начнёт RX/INIT после возврата. */
link->active_users++;
+51 -3
View File
@@ -9,6 +9,10 @@
#include "../../lib/debug_config.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#if ETCP_LINK_SOCKET_NAME_SIZE != MAX_CONN_NAME_LEN
#error "link snapshot socket name must fit the complete socket name"
#endif
struct native_adapter {
struct packet_link_events events;
@@ -178,7 +182,8 @@ int etcp_link_io_create(struct ETCP_LINK* link) {
io->state = (struct packet_link_state){link->is_tcp ? PACKET_LINK_CONNECTING : PACKET_LINK_OPEN,
PACKET_DATA_SIZE, !link->is_tcp, 0};
struct packet_link_driver driver = {io, link->etcp->instance->ua, link->is_tcp ? "STCP" : "UDP",
bind_native, native_state, adapter_send, rx_enabled, stop, destroy};
bind_native, native_state, adapter_send, rx_enabled, stop, destroy,
link->is_server ? PACKET_LINK_INCOMING : PACKET_LINK_OUTGOING};
if (etcp_link_io_bind(link, &driver)) { destroy(io); return -1; }
link->io->native = io;
queue_set_callback(io->rx, rx_drain, io);
@@ -197,20 +202,63 @@ int etcp_link_io_bind(struct ETCP_LINK* link, const struct packet_link_driver* d
io->channel = packet_link_bind(driver, &callbacks, io);
if (!io->channel) { u_free(io); return -1; }
link->io = io;
DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "[%s] packet path=%u driver=%s", link->etcp->log_name,
link->local_link_id, packet_link_name(io->channel));
DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "[%s] packet path=%u driver=%s direction=%d", link->etcp->log_name,
link->local_link_id, packet_link_name(io->channel), packet_link_get_direction(io->channel));
return 0;
}
void etcp_link_io_destroy(struct ETCP_LINK* link) {
if (!link || !link->io) return;
struct etcp_link_io* io = link->io; link->io = NULL;
DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "[%s] packet path closed=%u driver=%s direction=%d", link->etcp->log_name,
link->local_link_id, packet_link_name(io->channel), packet_link_get_direction(io->channel));
packet_link_close(io->channel); u_free(io);
}
struct packet_link* etcp_link_io_get(struct ETCP_LINK* link) { return link && link->io ? link->io->channel : NULL; }
int etcp_link_io_ready(struct ETCP_LINK* link) { return link && (!link->io || packet_link_get_state(link->io->channel).tx_ready); }
static int snapshot_address(const struct sockaddr_storage* source, struct etcp_link_address* out) {
if (!source || !source->ss_family) return 0;
if (source->ss_family == AF_INET) {
const struct sockaddr_in* value = (const struct sockaddr_in*)source;
out->family = 4; memcpy(out->bytes, &value->sin_addr, 4); out->port = ntohs(value->sin_port);
} else if (source->ss_family == AF_INET6) {
const struct sockaddr_in6* value = (const struct sockaddr_in6*)source;
out->family = 6; memcpy(out->bytes, &value->sin6_addr, 16); out->port = ntohs(value->sin6_port);
} else {
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "link snapshot: unsupported address family=%d", source->ss_family); return -1;
}
return 0;
}
int etcp_link_get_snapshot(const struct ETCP_LINK* link, struct etcp_link_snapshot* out) {
if (!link || link->closing || !out) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "link snapshot: missing/closing link or output"); return -1; }
struct etcp_link_snapshot s = {0};
s.id = link->local_link_id; s.state = link->link_state; s.status = link->link_status;
int external = link->io && !link->io->native;
const char* protocol;
if (external) {
protocol = packet_link_name(link->io->channel);
s.direction = packet_link_get_direction(link->io->channel);
} else {
s.has_ip = 1;
s.direction = link->is_server ? PACKET_LINK_INCOMING : PACKET_LINK_OUTGOING;
protocol = link->reality_set ? "TCP+REALITY" : link->is_tcp ? "TCP" : "UDP";
const struct sockaddr_storage* local = NULL;
if (link->conn) local = link->conn->interface_addr.ss_family ? &link->conn->interface_addr : &link->conn->local_addr;
else if (link->is_tcp && link->tcp_link) local = stcp_link_get_local_addr(link->tcp_link);
if (snapshot_address(local, &s.local) || snapshot_address(&link->remote_addr, &s.remote)) return -1;
if (link->conn) snprintf(s.socket_name, sizeof(s.socket_name), "%s", link->conn->name);
}
if (snprintf(s.protocol, sizeof(s.protocol), "%s", protocol) >= (int)sizeof(s.protocol))
DEBUG_WARN(DEBUG_CATEGORY_ETCP, "link snapshot: truncated driver name path=%u name=%s", s.id, protocol);
DEBUG_TRACE(DEBUG_CATEGORY_ETCP, "link snapshot path=%u protocol=%s direction=%d ip=%u state=%u status=%u",
s.id, s.protocol, s.direction, s.has_ip, s.state, s.status);
*out = s;
return 0;
}
int etcp_link_io_send(struct ETCP_LINK* link, const void* bytes, size_t size) {
if (!link || !bytes || !size || size > PACKET_DATA_SIZE) {
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "packet adapter invalid TX size=%zu", size); return -1;
+19
View File
@@ -5,6 +5,7 @@
#define ETCP_LINK_IO_H
#include "../../lib/packet_link_backend.h"
#include "../../lib/socket_compat.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
@@ -12,6 +13,24 @@ struct ETCP_DGRAM;
struct ETCP_LINK;
struct ETCP_SOCKET;
struct etcp_link_io;
#define ETCP_LINK_PROTOCOL_SIZE 32
#define ETCP_LINK_SOCKET_NAME_SIZE 64
/* Собственные значения для диагностики; адреса в сетевом порядке, порт — host order. */
struct etcp_link_address {
uint8_t family; /* 0 — неизвестен, 4/6 — IP. */
uint8_t bytes[16]; /* Для IPv4 используются первые 4 байта. */
uint16_t port;
};
struct etcp_link_snapshot {
uint8_t id, state, status; /* ID и фактическая готовность ETCP, не нижнего транспорта. */
enum packet_link_direction direction; /* Физическое подключение, независимо от роли ETCP. */
uint8_t has_ip; /* IP-путь: адрес может быть ещё неизвестен; у внешнего драйвера 0. */
struct etcp_link_address local, remote;
char protocol[ETCP_LINK_PROTOCOL_SIZE]; /* Подпись для GUI, не используется для выбора поведения. */
char socket_name[ETCP_LINK_SOCKET_NAME_SIZE]; /* Пусто для внешнего драйвера. */
};
/* В uasync скопировать описание живого линка без callbacks. 0/-1 с логом; при ошибке out не изменён. */
int etcp_link_get_snapshot(const struct ETCP_LINK* link, struct etcp_link_snapshot* out);
/* Фабрика встроенного UDP/STCP-адаптера; 0/-1. Не начинает handshake. */
int etcp_link_io_create(struct ETCP_LINK* link);
/* Привязать произвольный драйвер; успех забирает ctx, отказ сохраняет у вызывающего. */
+34 -1
View File
@@ -74,7 +74,7 @@ static void destroy(void* arg) {
struct pipe_driver* p = arg; assert(p->stopped && !p->destroyed); p->destroyed = 1;
}
static struct packet_link_driver api(struct pipe_driver* p) {
return (struct packet_link_driver){p, p->ua, "memory-packet", bind_pipe, state, send_pipe, enable, stop, destroy};
return (struct packet_link_driver){p, p->ua, "memory-packet", bind_pipe, state, send_pipe, enable, stop, destroy, PACKET_LINK_DIRECTION_UNKNOWN};
}
static void receive(struct ETCP_CONN* conn, struct ll_entry* packet) {
struct endpoint* ep = conn == endpoints[0].conn ? &endpoints[0] : &endpoints[1];
@@ -128,10 +128,28 @@ static void close_initial(struct ETCP_CONN* conn, struct ETCP_LINK* link, int ol
}
int main(void) {
debug_config_init(); debug_set_level(getenv("UTUN_TEST_DEBUG") ? DEBUG_LEVEL_DEBUG : DEBUG_LEVEL_WARN);
struct ETCP_SOCKET socket = {0}; strcpy(socket.name, "test-socket");
struct sockaddr_in* local = (struct sockaddr_in*)&socket.local_addr;
local->sin_family = AF_INET; local->sin_port = htons(1234); local->sin_addr.s_addr = htonl(0x7f000001);
struct ETCP_LINK native = {.conn = &socket, .is_server = 1, .link_state = 3, .link_status = 1};
struct sockaddr_in6* remote = (struct sockaddr_in6*)&native.remote_addr;
remote->sin6_family = AF_INET6; remote->sin6_port = htons(443); remote->sin6_addr.s6_addr[15] = 1;
struct etcp_link_snapshot snapshot;
assert(!etcp_link_get_snapshot(&native, &snapshot));
assert(snapshot.has_ip && !strcmp(snapshot.protocol, "UDP") && !strcmp(snapshot.socket_name, "test-socket"));
assert(snapshot.direction == PACKET_LINK_INCOMING && snapshot.state == 3 && snapshot.status == 1);
assert(snapshot.local.family == 4 && snapshot.local.port == 1234 && snapshot.local.bytes[0] == 127);
assert(snapshot.remote.family == 6 && snapshot.remote.port == 443 && snapshot.remote.bytes[15] == 1);
native.is_tcp = 1; native.is_server = 0;
assert(!etcp_link_get_snapshot(&native, &snapshot) && !strcmp(snapshot.protocol, "TCP"));
assert(snapshot.direction == PACKET_LINK_OUTGOING);
native.reality_set = 1;
assert(!etcp_link_get_snapshot(&native, &snapshot) && !strcmp(snapshot.protocol, "TCP+REALITY"));
size_t baseline = u_get_allocated_count(); setup();
struct pipe_driver pipes[2] = {{.ua = ua}, {.ua = ua, .connecting = 1}};
pipes[0].peer = &pipes[1]; pipes[1].peer = &pipes[0];
struct packet_link_driver drivers[2] = {api(&pipes[0]), api(&pipes[1])};
drivers[0].direction = PACKET_LINK_OUTGOING; drivers[1].direction = PACKET_LINK_INCOMING;
pipes[0].refuse = 1;
assert(!etcp_link_add(endpoints[0].conn, &drivers[0]));
assert(!endpoints[0].conn->links && !pipes[0].bound && !pipes[0].stopped && !pipes[0].destroyed);
@@ -141,10 +159,23 @@ int main(void) {
links[i] = etcp_link_add(endpoints[i].conn, &drivers[i]); assert(links[i]);
assert(!links[i]->conn && !links[i]->is_tcp && !pipes[i].sent);
}
struct etcp_link_snapshot snapshots[2];
for (int i = 0; i < 2; i++) {
assert(!etcp_link_get_snapshot(links[i], &snapshots[i]));
assert(!strcmp(snapshots[i].protocol, "memory-packet") && !snapshots[i].has_ip && !snapshots[i].state);
assert(snapshots[i].direction == (i ? PACKET_LINK_INCOMING : PACKET_LINK_OUTGOING));
assert(!snapshots[i].local.family && !snapshots[i].remote.family && !snapshots[i].socket_name[0]);
assert(!links[i]->is_server); /* Направление драйвера не меняет роль handshake. */
}
/* API скопирован: исходная таблица больше не нужна. */
memset(drivers, 0, sizeof(drivers));
uasync_drain_immediate(ua); assert(!endpoints[1].conn->links_up && !pipes[1].enabled);
pipes[1].connecting = 0; pipes[1].events.state_changed(pipes[1].arg); wait_ready();
for (int i = 0; i < 2; i++) {
assert(!etcp_link_get_snapshot(links[i], &snapshots[i]));
assert(snapshots[i].state == 3 && snapshots[i].status == 1);
assert(snapshots[i].direction == (i ? PACKET_LINK_INCOMING : PACKET_LINK_OUTGOING));
}
struct packet_link_driver duplicate = api(&pipes[0]);
assert(!etcp_link_add(endpoints[0].conn, &duplicate) && !pipes[0].stopped);
for (int i = 0; i < 2; i++) { pipes[i].held = 1; pipes[i].events.state_changed(pipes[i].arg); }
@@ -178,6 +209,8 @@ int main(void) {
struct ETCP_LINK* extra_link = etcp_link_add(endpoints[0].conn, &extra_api); assert(extra_link);
etcp_link_close(links[0]); assert(pipes[0].stopped && !pipes[0].destroyed && !extra.stopped);
assert(endpoints[0].conn->links == extra_link);
assert(!strcmp(snapshots[0].protocol, "memory-packet") && snapshots[0].direction == PACKET_LINK_OUTGOING);
assert(!etcp_link_get_snapshot(extra_link, &snapshots[0]) && snapshots[0].direction == PACKET_LINK_DIRECTION_UNKNOWN);
for (int i = 0; i < 2; i++) etcp_connection_close(endpoints[i].conn);
uasync_drain_immediate(ua);
assert(pipes[0].destroyed && pipes[1].destroyed && extra.destroyed);
+6 -1
View File
@@ -9,6 +9,7 @@
#include "../lib/debug_config.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
struct fixture {
struct packet_link* link;
@@ -64,11 +65,15 @@ static int emit(struct fixture* f, struct ll_entry* p) {
f->events.received(f->event_arg, p); return PACKET_LINK_OK;
}
static void create(struct fixture* f, struct UASYNC* ua) {
const struct packet_link_driver driver = {f, ua, "test", bind_driver, get_state, accept_packet, enable, stop, destroy};
char name[] = "test";
struct packet_link_driver driver = {f, ua, name, bind_driver, get_state, accept_packet, enable, stop, destroy, PACKET_LINK_OUTGOING};
const struct packet_link_callbacks callbacks = {changed, received};
const struct packet_link_state initial = {PACKET_LINK_OPEN, 1500, 1, 0};
f->state = initial;
f->link = packet_link_bind(&driver, &callbacks, f); assert(f->link);
driver.direction = PACKET_LINK_DIRECTION_UNKNOWN; name[0] = 'X';
assert(!strcmp(packet_link_name(f->link), "test"));
assert(packet_link_get_direction(f->link) == PACKET_LINK_OUTGOING);
assert(!f->states && !f->enabled);
}
int main(void) {
-4
View File
@@ -1,4 +0,0 @@
.gradle/
.kotlin/
**/build/
local.properties
-251
View File
@@ -1,251 +0,0 @@
# uTun BLE Test
Отдельное Android-приложение для проверки BLE discovery и автоматических двусторонних GATT-соединений.
Android 8.0+, compile/target SDK 36, JDK 17. `:app` — тестовый экран;
`:ble-transport` — переиспользуемая Android-библиотека с сервисом и очередями, без зависимости от экрана.
## Сборка и запуск
```bash
cd tools/ble-test-android
./build.sh # unit tests, debug APK, Android lint
./build.sh install DEVICE_SERIAL # то же + установка на выбранное устройство
```
APK: `app/build/outputs/apk/debug/app-debug.apk`, package: `com.utun.bletest`.
Gradle wrapper используется из соседнего `chatgui-android`; NDK, Compose и native-ядро не нужны.
На двух и более устройствах задайте разные ники и одинаковый период, например 1000 мс.
Нажмите «Запустить тест», выдайте разрешения и включите Bluetooth по запросу.
Ник занимает 1–13 **байт UTF-8**: латиница — один байт, кириллица обычно два.
Период — 250–3600000 мс. Для изменения настроек остановите сервис и запустите снова.
На Android 8–11 нужны разрешение геопозиции и включённая системная геопозиция.
Android 12+ запрашивает Nearby devices, Android 13+ отдельно запрашивает уведомления;
отказ от уведомлений не запрещает тест.
GUI отображает успешные публикации TX, уникальные сообщения RX по никам, повторы,
номер последнего сообщения, RSSI и давность приёма каждого сеанса.
Отдельно видны ошибки, очереди, переполнения и пропуски периодического источника.
Одинаковые ники суммируются, идентификаторы сеансов остаются видимыми.
Установка получает постоянный ненулевой 64-битный ID, видимый над настройками и в статистике соседей.
Флажок автоматического подключения включает discovery → запрос → callback допуска → READY без bonding/диалогов.
Направление автоматического запроса выбирается по unsigned ID: меньший подключается к большему.
Можно явно задать ID назначения. Поле допуска ограничивает входящие одним ID; пустое разрешает любые ID в тестовом приложении.
В библиотеке отсутствие callback означает отказ. Настройки допуска применяются при запуске теста.
## Фон и проверка стабильности
Сервис `connectedDevice` работает с постоянным уведомлением и partial wake lock.
Закрытие Activity, Home, блокировка экрана и удаление задачи из recent apps его не останавливают.
Остановка — кнопкой на экране или в уведомлении. Wake lock удерживается только во время теста:
он намеренно расходует батарею ради непрерывной работы таймеров при погашенном экране.
После уничтожения процесса система может восстановить START_STICKY-сервис с сохранёнными параметрами;
при этом создаётся новый сеанс, а статистика и очереди в памяти начинаются заново.
Force stop, перезагрузка телефона и ограничения конкретной прошивки не дают гарантии восстановления.
Автозапуска после загрузки нет. При необходимости разрешите фоновую работу в настройках прошивки.
Рекомендуемый ручной прогон:
1. Сверить рост TX и RX на двух телефонах при видимых экранах, проверить «Отправить сейчас».
2. Уйти на Home и погасить экраны на 15–30 минут; затем сверить новые сообщения и давность приёма.
3. Выключить и включить Bluetooth на одном телефоне: сервис ждёт адаптер и восстанавливает радио.
4. После ошибки BLE проверить повторный запуск через 30 секунд и причину в GUI/logcat.
5. Проверить stop/start, поворот экрана и повторное открытие приложения.
«Отправлено» означает успешный callback Bluetooth-стека о публикации новых данных.
Одна публикация многократно объявляется контроллером до следующего обновления;
это не число физических радиообъявлений и не подтверждение получения другими устройствами.
Нельзя вычислять точный процент радиопотерь только по этим счётчикам: есть повторы,
позднее подключение, перезапуски и время без сканирования. Последний `seq` помогает сравнить прогресс.
Android BLE не гарантирует доставку, точный период или непрерывную работу на любой прошивке.
Ограничения фоновой работы описаны в [Android BLE background guide](https://developer.android.com/develop/connectivity/bluetooth/ble/background).
## Binder API
Сервис принадлежит приложению, `exported=false`, использует тот же процесс.
Запустите его из видимой Activity после выдачи `requiredPermissions()`, затем привяжитесь через `bindService`.
`onServiceConnected` получает `BleBroadcastService.LocalBinder`.
```kotlin
// Периодический источник произвольных байтов; null отключает источник, оставляя ручной TX и RX.
BleBroadcastService.start(context, periodicPayload = "Phone-1".toByteArray(), periodMs = 1000)
context.bindService(
Intent(context, BleBroadcastService::class.java),
connection,
Context.BIND_AUTO_CREATE
)
// Из onServiceConnected:
val api = binder as BleBroadcastService.LocalBinder
val result = api.send(payload) // неблокирующая запись; копирует payload
val packet = api.pollReceived() // одно сообщение или null
val batch = api.drainReceived(64) // до 64 сообщений; limit > 0
val stats = api.snapshot() // потокобезопасный снимок, не забирает сообщения
BleBroadcastService.stop(context)
```
## API подключения и обмена с узлом
При интеграции передавайте ID существующего ядра через `localNodeId`; по умолчанию берётся постоянный ID установки.
Для смены собственного ID сначала остановите транспорт. Все 64 бита передаются без усечения:
Kotlin `Long` хранит unsigned ID как битовое представление, для текста используйте `BleProtocol.nodeIdText(id)`.
```kotlin
BleBroadcastService.start(context, "Phone-1".toByteArray(), 1000,
targetNodeId = remoteId, localNodeId = myNodeId)
// После привязки. Callback принадлежит приложению/сервису, а не Activity.
api.setConnectionValidator { request ->
request.localNodeId == myNodeId && allowedNodeIds.contains(request.remoteNodeId)
}
val requested = api.connect(remoteId) // поиск адреса по broadcasts, connectGatt, HELLO, повтор после обрыва
val result = api.sendTo(remoteId, payload)
val packet: ByteArray? = api.pollNodeReceived(remoteId)
val packets: List<ByteArray> = api.drainNodeReceived(remoteId, limit = 64)
val links = api.connectionSnapshots()
api.disconnect(remoteId) // отменяет повторные попытки и автоматическое подключение к этому ID
```
`start(..., autoConnect=true)` включает автоматический запрос к обнаруженным узлам.
Параметры `start` сохраняются для sticky restart, явно добавленные Binder-запросы действуют до stop/смерти процесса.
`connect` возвращает `REQUESTED`, `STOPPED`, `INVALID_ID` или `LIMIT_REACHED`;
`REQUESTED` означает принятую заявку, готовность видна как `READY` в снимке.
Одновременно допускается до 8 транспортов, история очередей ограничена 32 ID до пересоздания сервиса.
Для восстановления политики после смерти процесса класс `Application` может реализовать `BleConnectionValidator`;
сервис возьмёт этот callback при создании. Пример — `TestApplication`.
Callback выполняется синхронно в BLE worker, должен быстро вернуть `Boolean`, исключение означает отказ.
Он получает собственный ID, ID отправителя HELLO и имя из discovery, если оно уже получено.
Сервис сначала проверяет HELLO `source != 0`, `source != local`, `target == local`;
инициатор сверяет ID в ACCEPT с ожидаемым ID назначения. DATA до допуска запрещён.
ID здесь заявленный, без криптографического доказательства владения. Для чата аутентификацию выполняет его протокол.
`sendTo` возвращает `QUEUED`, `NOT_CONNECTED`, `QUEUE_FULL` или `TOO_LARGE`.
Максимум сообщения — 4096 байт. Для каждого ID отдельные FIFO TX и RX по 64 сообщения;
входной payload копируется. При `QUEUE_FULL` дождитесь уменьшения `txQueued`.
GUI умеет отправлять текст и пачку 64 × 512 байт, показывать MTU, байты, очереди, ожидание ACK и блокировку RX.
Флажок чтения RX позволяет остановить consumer и проверить backpressure.
Congestion/flow control консервативный: окно — один фрагмент, следующий выпускается по ACK предыдущего.
Размер фрагмента следует согласованному MTU (23–517), ATT value ограничен 512 байтами.
Сервер передаёт indications, клиент — writes с ответом; один ATT writer сериализует операции.
Служебный ACK имеет приоритет над DATA, поэтому одновременный обмен не блокирует подтверждения.
При полном RX финальный ACK сообщения удерживается, сохраняется одно дополнительное собираемое сообщение;
чтение RX возвращает кредит и возобновляет отправителя. RX соединения ничего не вытесняет.
ACK означает помещение сообщения в RX удалённого API, счётчик `sent` растёт только после этого.
При обрыве неподтверждённый TX сбрасывается с `discardedTx`, готовый RX сохраняется в пределах жизни сервиса.
Приложение само решает, какие сообщения повторять: финальный ACK мог потеряться уже после помещения в RX.
Handshake ограничен 30 с, отдельная ATT операция — 20 с; после обрыва заявка повторяется через 10 с.
Ожидание свободного RX не имеет короткого таймаута: consumer может быть остановлен намеренно.
Wire GATT: HELLO/ACCEPT — `type:u8 | source:u64le | target:u64le` (типы 1/2);
DATA — `3 | sequence:u32le | offset:u16le | total:u16le | fragment`;
ACK — `4 | sequence:u32le | acceptedOffset:u16le`; REJECT — байт 5.
Счётчики последовательности независимы по направлениям, начинаются с 1 на новом соединении.
`send` возвращает `QUEUED`, `STOPPED`, `QUEUE_FULL`, `PAYLOAD_TOO_LARGE` или `SEQUENCE_EXHAUSTED`.
При `QUEUE_FULL` дождитесь уменьшения `snapshot().txQueued` перед повтором, не накапливайте новые сообщения.
Одна FIFO TX на 64 записи; головная запись снимается только после успешного callback.
При сбое радио запись сохраняется и повторяется с тем же номером. При явном stop ожидающий TX
удаляется и учитывается в `txDiscarded`. Уже опубликованный пакет передаётся минимум 250 мс перед следующим.
Периодический источник пропускает тик при занятом TX/отключённом радио, не раздувая очередь.
RX — одна FIFO на 256 уникальных сообщений, с одним логическим consumer.
При заполнении удаляется старейшее сообщение, растёт `rxDropped`, появляется warning.
GUI забирает RX при видимом экране, но сервис ведёт статистику независимо от чтения очереди.
После длительной работы в фоне переполнение RX ожидаемо; оно не означает потерю радиопакетов.
`BleReceived` содержит `session`, `sequence`, `payload`, `rssi`, `elapsedMs`.
Время — монотонное Android elapsed realtime, не UTC.
Идентификатор сеанса — случайное ненулевое unsigned 32-bit число, новый при каждом запуске транспорта.
Дедупликация по `(session, sequence)`, окно перестановок — 256 сообщений.
Повторы и пакеты старше окна учитываются отдельно, в RX не попадают.
Статистика ограничена 256 сеансами; при превышении вытесняется дольше всего не наблюдавшийся,
растёт `peerEvictions`. Общие счётчики сохраняются. Снимки и входные payload копируются.
История и идентичность сеансов не сохраняются в БД. Переполнение unsigned sequence требует нового запуска.
## Подключение к chatgui-android
В `tools/chatgui-android/settings.gradle.kts`:
```kotlin
include(":ble-transport")
project(":ble-transport").projectDir = file("../ble-test-android/ble-transport")
```
В `app/build.gradle.kts` добавить `implementation(project(":ble-transport"))`,
в корневой plugins-блок — `id("com.android.library") version "8.13.2" apply false`.
Manifest библиотеки добавит BLE-права и сервис. Приложение отвечает за runtime-разрешения,
привязку, чтение RX и явный stop. Unbind останавливает только связь GUI с API.
Для транспорта, привязанного к жизненному циклу чат-сервиса, запускать/останавливать этот сервис явно;
текущий API не добавляет BLE в native-ядро и не требует изменений протокола чата.
## Радиоформат и диагностика
Один legacy advertising set, connectable/scannable, LE 1M, интервал объявления 100 мс.
Advertising данные меняются через `setAdvertisingData`, без цикла stop/start для каждого сообщения.
Сканирование LOW_LATENCY с manufacturer-фильтром и нулевой задержкой отчётов.
API Android описан в [AdvertisingSet](https://developer.android.com/reference/android/bluetooth/le/AdvertisingSet).
Manufacturer Company ID `0xffff` используется только для локального эксперимента;
перед production нужен собственный Company ID или другой AD-формат.
Manufacturer data: `75 54 | version:u8=2 | session:u32le | sequence:u32le | payload:0..13 bytes`.
Всего до 24 байт, плюс 4 байта manufacturer AD и резерв 3 байта flags: укладывается в 31 байт legacy BLE.
Scan response содержит service data UUID `f61a9001-4abc-45ac-98e1-761df4c13811`:
`version:u8=2 | nodeId:u64le` (9 байт + 18 байт AD). Android возвращает имя и ID в объединённом ScanRecord.
Имя обновляется вместе со счётчиком; ID неизменен во время работы транспорта. Обратной совместимости с v1 нет.
Ник — соглашение тестового приложения; транспорт принимает любые байты.
Broadcast discovery не имеет ACK. Для данных соединения есть фрагментация/ACK/очереди, шифрование и аутентификация — задача чата.
```bash
adb -s DEVICE_SERIAL logcat -v threadtime -s UtunBle
adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle DEBUG # дополнительно каждый опубликованный TX
adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle INFO
```
INFO: запуск/остановка, состояние адаптера, готовность advertising, сводка каждые 15 с.
WARN/ERROR: переполнения, отказ API, некорректные пакеты, код/название ошибки BLE, callback timeout,
ошибки очистки и отозванные права. Ошибка радио закрывает обе операции и запускает повтор через 30 с;
поздние callbacks старого поколения игнорируются. Таймаут advertising callback — 10 с.
Unit tests проверяют wire-формат и лимит размера, unsigned поля, malformed данные, backpressure,
дедупликацию/перестановки, переполнение RX, независимость сеансов, ограничение памяти,
копирование payload, stop/start и одновременную запись из нескольких потоков.
Радиоприём, Doze и поведение конкретного BLE-чипа требуют проверки на физических телефонах.
## Выполненная проверка — 28.10.2026
11 unit tests PASS; lint приложения и библиотеки — без замечаний. Собраны debug APK и
`ble-transport/build/outputs/aar/ble-transport-debug.aar`.
На Q8 / Android 14 окончательная сборка выполнила 199 публикаций за короткий прогон:
проверены работа со свёрнутой Activity и погашенным экраном, ручной TX через Binder,
восстановление после off/on Bluetooth и явная остановка; итоговый счётчик ошибок — 0.
Подключено одно устройство: физический приём между телефонами и длительный прогон ещё не проверены.
Общий `./check.sh` вне песочницы: 223 PASS / 3 FAIL из-за недоступного IPv6 loopback `::1`
(`test_ncd_faults`, `test_ipv6_sockets`, `test_route_connectivity`); известная проблема среды уже описана в `doc/tasks.md`.
## Проверка GATT API на двух телефонах
```bash
./gradlew :ble-transport:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest :app:lintDebug :ble-transport:lintDebug
python3 tests/run_two_devices.py --install \
--receiver Q8_SERIAL --receiver-id Q8_NODE_ID \
--initiator SAMSUNG_SERIAL --initiator-id SAMSUNG_NODE_ID \
--output /tmp/ble-gatt-duplex
# Та же команда с --reject и другой --output проверяет отказ валидации.
```
Runner запускает оба instrumentation процесса одновременно. `BleApiProbe` обращается к Binder API без GUI consumer,
отправляет в обе стороны по 70 сообщений (69 × 512 + 4096 байт), проверяет содержимое и порядок,
заполняет RX, выдерживает паузу без кредита и затем проверяет продолжение.
После прогона сервис останавливается. Instrumentation код находится только в отдельном test APK.
Модульных тестов — 18: discovery, все 64 бита ID, ACK window, фрагментация при MTU 23/64/247/517,
нулевые/максимальные сообщения, duplex, неправильные ACK/offset/size, TX/RX backpressure и disconnect.
На Q8 и Samsung SM-A525F оба прогона PASS: разрешение/отказ callback и duplex по 70 сообщений,
39424 байта в каждом направлении, MTU 517. Обе стороны зафиксировали заполнение RX и возобновление после возврата кредита;
содержимое и порядок проверены побайтово. 18 unit tests PASS, lint обоих модулей без замечаний.
Повторный общий прогон: 223 PASS / те же 3 FAIL из-за недоступного `::1`.
@@ -1,24 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.utun.bletest"
compileSdk = 36
defaultConfig {
applicationId = "com.utun.bletest"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "com.utun.bletest.BleApiProbe"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
dependencies {
implementation(project(":ble-transport"))
}
@@ -1,115 +0,0 @@
package com.utun.bletest
import android.app.Activity
import android.app.Instrumentation
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.os.SystemClock
import com.utun.ble.*
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/** Два физических телефона: Binder API, duplex, заполнение RX, возобновление кредита и отказ политики. */
class BleApiProbe : Instrumentation() {
private lateinit var args: Bundle
override fun onCreate(arguments: Bundle) {
super.onCreate(arguments)
args = arguments
start()
}
override fun onStart() {
val result = Bundle()
var resultCode = Activity.RESULT_OK
var bound = false
var api: BleBroadcastService.LocalBinder? = null
val boundLatch = CountDownLatch(1)
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
api = service as BleBroadcastService.LocalBinder
boundLatch.countDown()
}
override fun onServiceDisconnected(name: ComponentName) { api = null }
}
try {
val peer = java.lang.Long.parseUnsignedLong(checkNotNull(args.getString("peer")), 16)
val initiator = args.getString("initiator") == "true"
val reject = args.getString("reject") == "true"
val activity = startActivitySync(Intent(targetContext, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
bound = targetContext.bindService(Intent(targetContext, BleBroadcastService::class.java), connection, Context.BIND_AUTO_CREATE)
check(bound && boundLatch.await(10, TimeUnit.SECONDS)) { "Binder unavailable" }
val service = checkNotNull(api)
service.connectionSnapshots().forEach { service.disconnect(it.nodeId) }
val validations = AtomicInteger()
service.setConnectionValidator { request ->
validations.incrementAndGet()
!reject && request.localNodeId == service.nodeId() && request.remoteNodeId == peer
}
runOnMainSync { BleBroadcastService.start(targetContext, "API-probe".toByteArray(), 1000) }
val deadline = SystemClock.elapsedRealtime() + 180_000
fun await(test: () -> Boolean) {
while (!test()) {
check(SystemClock.elapsedRealtime() < deadline) { "Timeout: ${service.connectionSnapshots()}" }
Thread.sleep(50)
}
}
await { service.snapshot().running }
runOnMainSync { activity.moveTaskToBack(true) }
if (initiator) check(service.connect(peer) == ConnectResult.REQUESTED)
if (reject) {
await {
if (initiator) service.connectionSnapshots().any { it.lastError.contains("Remote validation rejected") }
else validations.get() > 0
}
if (!initiator) Thread.sleep(1500) // Дать REJECT indication дойти до инициатора до cleanup.
check(service.connectionSnapshots().none { it.state == "READY" }) { "Rejected peer became READY" }
result.putString("result", "PASS rejection validations=${validations.get()}")
} else {
await { service.connectionSnapshots().any { it.nodeId == peer && it.state == "READY" } }
var sent = 0
var received = 0
var blockedAt = 0L
var blocked = false
while (sent < 70 || received < 70) {
check(SystemClock.elapsedRealtime() < deadline) { "Duplex timeout sent=$sent received=$received ${service.connectionSnapshots()}" }
val stats = service.connectionSnapshots().single { it.nodeId == peer }
check(stats.state == "READY") { "Disconnected during transfer: $stats" }
if (sent < 70 && stats.txQueued < 64) {
check(service.sendTo(peer, payload(sent)) == NodeSendResult.QUEUED)
sent++
}
if (stats.rxBlocked && !blocked) {
blocked = true
blockedAt = SystemClock.elapsedRealtime()
check(stats.rxQueued == 64) { "RX exceeded/undershot capacity: $stats" }
}
if (blocked && SystemClock.elapsedRealtime() - blockedAt >= 1000) {
service.drainNodeReceived(peer).forEach { data ->
check(data.contentEquals(payload(received))) { "Corrupt/out of order message=$received bytes=${data.size}" }
received++
}
}
Thread.sleep(10)
}
await { service.connectionSnapshots().single { it.nodeId == peer }.sent == 70L }
check(blocked) { "RX backpressure was not exercised" }
result.putString("result", "PASS duplex sent=$sent received=$received rxBlocked=$blocked ${service.connectionSnapshots()}")
}
} catch (error: Throwable) {
resultCode = Activity.RESULT_CANCELED
result.putString("result", "FAIL ${error.stackTraceToString()}")
} finally {
api?.connectionSnapshots()?.forEach { api?.disconnect(it.nodeId) }
runOnMainSync { BleBroadcastService.stop(targetContext) }
if (bound) targetContext.unbindService(connection)
}
finish(resultCode, result)
}
private fun payload(index: Int): ByteArray = ByteArray(if (index == 69) 4096 else 512) { ((it * 31 + index) and 0xff).toByte() }
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application android:name=".TestApplication" android:label="uTun BLE Test" android:icon="@drawable/ic_ble"
android:allowBackup="false" android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,401 +0,0 @@
package com.utun.bletest
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
import android.text.InputType
import android.util.Log
import android.view.WindowInsets
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import com.utun.ble.BleBroadcastService
import com.utun.ble.BleProtocol
import com.utun.ble.BleSnapshot
import com.utun.ble.SendResult
import java.util.UUID
/** Тестовый клиент Binder API. Ни Bluetooth callbacks, ни фоновые таймеры здесь не живут. */
@SuppressLint("SetTextI18n") // Локальная тестовая утилита с русским экраном диагностики, без локализации.
class MainActivity : Activity() {
private val handler = Handler(Looper.getMainLooper())
private var api: BleBroadcastService.LocalBinder? = null
private var bound = false
private var consumed = 0L
private lateinit var nickname: EditText
private lateinit var period: EditText
private lateinit var targetId: EditText
private lateinit var allowedId: EditText
private lateinit var automatic: CheckBox
private lateinit var readNodeRx: CheckBox
private lateinit var connectionText: TextView
private lateinit var message: EditText
private var nodeConsumed = 0L
private var lastNodeMessage = ""
private lateinit var status: TextView
private lateinit var totals: TextView
private lateinit var peers: TextView
private lateinit var feedback: TextView
private lateinit var start: Button
private lateinit var stop: Button
private lateinit var send: Button
private val preferences by lazy { getSharedPreferences("test_ui", MODE_PRIVATE) }
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
api = service as BleBroadcastService.LocalBinder
refresh()
}
override fun onServiceDisconnected(name: ComponentName) {
api = null
status.text = "Соединение с сервисом потеряно"
Log.w(BleBroadcastService.TAG, "GUI service connection lost")
}
override fun onBindingDied(name: ComponentName) {
api = null
if (bound) unbindService(this)
bound = bindService(Intent(this@MainActivity, BleBroadcastService::class.java), this, Context.BIND_AUTO_CREATE)
Log.w(BleBroadcastService.TAG, "GUI rebinding after service death: bound=$bound")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(24))
setBackgroundColor(Color.rgb(245, 248, 251))
}
val scroll = ScrollView(this).apply { addView(root) }
scroll.setOnApplyWindowInsetsListener { view, insets ->
if (Build.VERSION.SDK_INT >= 30) {
val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() or WindowInsets.Type.ime())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
} else {
@Suppress("DEPRECATION")
view.setPadding(insets.systemWindowInsetLeft, insets.systemWindowInsetTop,
insets.systemWindowInsetRight, insets.systemWindowInsetBottom)
}
insets
}
setContentView(scroll)
scroll.requestApplyInsets()
fun label(value: String, size: Float = 16f): TextView = TextView(this).apply {
text = value
textSize = size
setTextColor(Color.rgb(24, 42, 61))
setPadding(0, dp(10), 0, dp(6))
root.addView(this)
}
label("uTun · BLE Test", 28f)
label("ID: ${BleProtocol.nodeIdText(BleBroadcastService.nodeId(this))}").setTextIsSelectable(true)
label("Проверка обмена BLE broadcasts. Приём и передача продолжаются в фоне с уведомлением.")
label("Ник устройства · до ${BleProtocol.MAX_PAYLOAD} байт UTF-8")
nickname = EditText(this).apply {
isSingleLine = true
setText(preferences.getString("nickname", "BLE-${UUID.randomUUID().toString().take(4)}"))
contentDescription = "Ник устройства"
root.addView(this)
}
label("Период отправки, мс · 250–3600000")
period = EditText(this).apply {
inputType = InputType.TYPE_CLASS_NUMBER
isSingleLine = true
setText(preferences.getLong("period", 1000).toString())
contentDescription = "Период отправки в миллисекундах"
root.addView(this)
}
label("ID назначения, hex · пусто — без явного запроса")
targetId = EditText(this).apply {
isSingleLine = true
contentDescription = "ID назначения"
setText(preferences.getString("targetText", ""))
root.addView(this)
}
label("Допуск входящих: ID, hex · пусто — любой ненулевой ID")
allowedId = EditText(this).apply {
isSingleLine = true
contentDescription = "Разрешённый ID"
setText(preferences.getString("allowedText", ""))
root.addView(this)
}
automatic = CheckBox(this).apply {
text = "Автоматически соединяться с обнаруженными узлами"
isChecked = preferences.getBoolean("automatic", true)
root.addView(this)
}
start = Button(this).apply {
text = "Запустить тест"
setOnClickListener { requestStart() }
root.addView(this)
}
stop = Button(this).apply {
text = "Остановить сервис"
setOnClickListener {
try { BleBroadcastService.stop(this@MainActivity) }
catch (error: RuntimeException) { showError("Не удалось остановить сервис", error) }
}
root.addView(this)
}
send = Button(this).apply {
text = "Отправить сейчас через очередь"
setOnClickListener {
val result = api?.send(nickname.text.toString().trim().toByteArray(Charsets.UTF_8))
feedback.text = when (result) {
SendResult.QUEUED -> "Сообщение добавлено в очередь отправки"
SendResult.QUEUE_FULL -> "Очередь занята; повторите позднее"
else -> "Отправка отклонена: ${result ?: "сервис не подключён"}"
}
}
root.addView(this)
}
feedback = label("")
status = label("Подключение к сервису…", 20f)
totals = label("")
label("Соединения GATT", 22f)
connectionText = label("Соединений пока нет")
readNodeRx = CheckBox(this).apply {
text = "Забирать данные из RX (снять для проверки backpressure)"
isChecked = true
root.addView(this)
}
message = EditText(this).apply {
contentDescription = "Сообщение узлу"
setText("Hello over GATT")
root.addView(this)
}
Button(this).apply {
text = "Подключить ID назначения через API"
setOnClickListener {
try {
val id = parseId(targetId.text.toString())
feedback.text = "connect: ${api?.connect(id)}"
} catch (e: RuntimeException) { showError("Неверный ID назначения", e) }
}
root.addView(this)
}
Button(this).apply {
text = "Отправить данные подключённым узлам"
setOnClickListener {
val service = api ?: return@setOnClickListener
val results = service.connectionSnapshots().filter { it.state == "READY" }.map {
"${BleProtocol.nodeIdText(it.nodeId)}: ${service.sendTo(it.nodeId, message.text.toString().toByteArray(Charsets.UTF_8))}"
}
feedback.text = results.joinToString("\n").ifEmpty { "Нет готовых соединений" }
}
root.addView(this)
}
Button(this).apply {
text = "Нагрузить TX: 64 сообщения × 512 байт"
setOnClickListener {
val service = api ?: return@setOnClickListener
var accepted = 0
var rejected = 0
service.connectionSnapshots().filter { it.state == "READY" }.forEach { peer ->
repeat(64) { number ->
val data = ByteArray(512) { ((it + number) and 0xff).toByte() }
if (service.sendTo(peer.nodeId, data) == com.utun.ble.NodeSendResult.QUEUED) accepted++ else rejected++
}
}
feedback.text = "Добавлено: $accepted; отклонено: $rejected"
}
root.addView(this)
}
Button(this).apply {
text = "Отключить соединения через API"
setOnClickListener { api?.let { service -> service.connectionSnapshots().forEach { service.disconnect(it.nodeId) } } }
root.addView(this)
}
label("Принято по никам", 22f)
peers = label("Другие устройства ещё не обнаружены")
label("Отправлено = обновлений данных, подтверждённых BLE-стеком. Доставка не подтверждается. " +
"Повторы радиообъявлений не увеличивают число принятых сообщений. " +
"Кириллица занимает несколько байт на символ. Счётчики хранятся до завершения процесса приложения.", 14f)
Button(this).apply {
text = "Настройки приложения"
setOnClickListener { startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.parse("package:$packageName"))) }
root.addView(this)
}
if (savedInstanceState != null) {
nickname.setText(savedInstanceState.getString("nickname"))
period.setText(savedInstanceState.getString("period"))
targetId.setText(savedInstanceState.getString("targetText"))
allowedId.setText(savedInstanceState.getString("allowedText"))
automatic.isChecked = savedInstanceState.getBoolean("automatic")
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
override fun onStart() {
super.onStart()
bound = bindService(Intent(this, BleBroadcastService::class.java), connection, Context.BIND_AUTO_CREATE)
if (!bound) showError("Не удалось подключиться к BLE сервису")
handler.post(tick)
}
override fun onStop() {
handler.removeCallbacks(tick)
if (bound) unbindService(connection)
bound = false
api = null
super.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putString("nickname", nickname.text.toString())
outState.putString("period", period.text.toString())
outState.putString("targetText", targetId.text.toString())
outState.putString("allowedText", allowedId.text.toString())
outState.putBoolean("automatic", automatic.isChecked)
super.onSaveInstanceState(outState)
}
private fun requestStart() {
val nick = nickname.text.toString().trim()
val interval = period.text.toString().toLongOrNull()
if (nick.isEmpty() || nick.toByteArray(Charsets.UTF_8).size > BleProtocol.MAX_PAYLOAD) {
showError("Укажите ник длиной 1–${BleProtocol.MAX_PAYLOAD} байт UTF-8")
return
}
if (interval == null || interval !in BleBroadcastService.MIN_PERIOD_MS..BleBroadcastService.MAX_PERIOD_MS) {
showError("Период должен быть от 250 до 3600000 мс")
return
}
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
showError("Устройство не поддерживает BLE")
return
}
if (!BleBroadcastService.hasPermissions(this)) {
requestPermissions(BleBroadcastService.requiredPermissions(), 100)
return
}
try {
val adapter = getSystemService(BluetoothManager::class.java)?.adapter
if (adapter == null) {
showError("Bluetooth адаптер отсутствует")
return
}
if (!adapter.isEnabled) {
@Suppress("DEPRECATION")
startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), 102)
return
}
val target = parseId(targetId.text.toString())
val allowed = parseId(allowedId.text.toString())
preferences.edit().putString("nickname", nick).putLong("period", interval)
.putString("targetText", targetId.text.toString()).putString("allowedText", allowedId.text.toString())
.putLong("allowedId", allowed).putBoolean("automatic", automatic.isChecked).apply()
BleBroadcastService.start(this, nick.toByteArray(Charsets.UTF_8), interval, target, automatic.isChecked)
feedback.text = "Тест запускается. Для сравнения задайте на другом устройстве другой ник."
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101)
}
} catch (error: RuntimeException) { showError("Не удалось запустить BLE тест", error) }
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 100) {
if (BleBroadcastService.hasPermissions(this)) requestStart()
else showError("Для BLE нужны разрешения. Если запрос больше не появляется, выдайте их в настройках приложения.")
} else if (requestCode == 101 && grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) {
feedback.text = "Уведомления отключены; сервис продолжит работу. Разрешить их можно в настройках приложения."
Log.w(BleBroadcastService.TAG, "Notification permission denied; foreground service remains active")
}
}
@Deprecated("Legacy Activity result callback")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 102) {
if (resultCode == RESULT_OK) requestStart()
else showError("Включение Bluetooth отменено")
}
}
private val tick = object : Runnable {
override fun run() {
refresh()
handler.postDelayed(this, 1000)
}
}
private fun refresh() {
val service = api ?: return
// GUI выступает consumer очереди, статистика сервиса остаётся полной и в фоне.
consumed += service.drainReceived(256).size
if (readNodeRx.isChecked) service.connectionSnapshots().forEach { peer ->
val data = service.drainNodeReceived(peer.nodeId)
nodeConsumed += data.size
data.lastOrNull()?.let {
lastNodeMessage = "${BleProtocol.nodeIdText(peer.nodeId)}: ${it.size} байт, hex " +
it.take(24).joinToString("") { b -> "%02x".format(b) }
}
}
connectionText.text = service.connectionSnapshots().joinToString("\n\n") {
"${BleProtocol.nodeIdText(it.nodeId)} · ${it.state} · MTU ${it.mtu}\n" +
"TX/RX ${it.sent}/${it.received} · байт ${it.sentBytes}/${it.receivedBytes}\n" +
"Очереди ${it.txQueued}/${it.rxQueued} · ждёт ACK ${it.waitingAck} · RX блокирует ${it.rxBlocked}\n" +
"Сброшено TX ${it.discardedTx}" + if (it.lastError.isEmpty()) "" else "\n${it.lastError}"
}.ifEmpty { "Соединений пока нет" } + "\nGUI прочитал: $nodeConsumed\n$lastNodeMessage"
render(service.snapshot())
}
private fun render(s: BleSnapshot) {
status.text = s.state
nickname.isEnabled = !s.running
period.isEnabled = !s.running
allowedId.isEnabled = !s.running
automatic.isEnabled = !s.running
start.isEnabled = !s.running
stop.isEnabled = s.running
send.isEnabled = s.running
totals.text = "Отправлено: ${s.sent} Принято: ${s.received}\n" +
"Повторов: ${s.duplicates} Устаревших: ${s.stale}\n" +
"Очереди TX / RX: ${s.txQueued} / ${s.rxQueued}\n" +
"GUI забрал из RX: $consumed\n" +
"Отказов TX: ${s.txRejected} · Сброшено TX: ${s.txDiscarded}\n" +
"Переполнений RX: ${s.rxDropped} · Пропущено периодов: ${s.periodicSkipped}\n" +
"Ошибок: ${s.errors} · Неверных пакетов: ${s.invalid} · Вытеснено устройств: ${s.peerEvictions}\n" +
"Сеанс: ${s.session.toString(16)}" + if (s.lastError.isNotEmpty()) "\nПоследняя ошибка: ${s.lastError}" else ""
val now = SystemClock.elapsedRealtime()
peers.text = if (s.peers.isEmpty()) "Другие устройства ещё не обнаружены" else s.peers
.groupBy { String(it.payload, Charsets.UTF_8) }.toSortedMap().entries.joinToString("\n\n") { (nick, sessions) ->
"${nick.ifEmpty { "(пустые данные)" }} — принято ${sessions.sumOf { it.received }}, повторов ${sessions.sumOf { it.duplicates }}\n" +
sessions.joinToString("\n") {
" ID ${BleProtocol.nodeIdText(it.nodeId)} · session ${it.session.toString(16)}\n" +
" seq ${it.lastSequence} · ${it.rssi} dBm · ${maxOf(0, now - it.lastSeenMs) / 1000} с назад"
}
}
}
private fun showError(message: String, error: RuntimeException? = null) {
feedback.text = if (error == null) message else "$message: ${error.message}"
Log.e(BleBroadcastService.TAG, feedback.text.toString(), error)
}
private fun parseId(text: String): Long {
if (text.isBlank()) return 0
require(text.trim().length <= 16) { "ID должен содержать до 16 hex цифр" }
return java.lang.Long.parseUnsignedLong(text.trim(), 16).also { require(it != 0L) { "ID 0 зарезервирован" } }
}
}
@@ -1,20 +0,0 @@
package com.utun.bletest
import android.app.Application
import android.util.Log
import com.utun.ble.BleBroadcastService
import com.utun.ble.BleConnectionRequest
import com.utun.ble.BleConnectionValidator
import com.utun.ble.BleProtocol
/** Политика тестового приложения живёт без Activity и восстанавливается после перезапуска процесса. */
class TestApplication : Application(), BleConnectionValidator {
override fun validate(request: BleConnectionRequest): Boolean {
val allowedId = getSharedPreferences("test_ui", MODE_PRIVATE).getLong("allowedId", 0)
val accepted = request.localNodeId == BleBroadcastService.nodeId(this) && request.remoteNodeId != 0L &&
request.remoteNodeId != request.localNodeId && (allowedId == 0L || request.remoteNodeId == allowedId)
Log.i(BleBroadcastService.TAG, "App validation: remote=${BleProtocol.nodeIdText(request.remoteNodeId)}" +
" allowed=${BleProtocol.nodeIdText(allowedId)} accepted=$accepted")
return accepted
}
}
@@ -1,6 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#1768AC" android:pathData="M0,0h48v48h-48z" />
<path android:fillColor="#FFFFFF" android:fillType="evenOdd"
android:pathData="M22,4v16l-9,-9 -3,3 11,10 -11,10 3,3 9,-9v16l16,-12 -10,-8 10,-8z M26,12l6,4 -6,5z M26,27l6,5 -6,4z" />
</vector>
@@ -1,8 +0,0 @@
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -1,17 +0,0 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.utun.ble"
compileSdk = 36
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
dependencies {
testImplementation("junit:junit:4.13.2")
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service android:name="com.utun.ble.BleBroadcastService" android:exported="false"
android:foregroundServiceType="connectedDevice" android:stopWithTask="false" />
</application>
</manifest>
@@ -1,533 +0,0 @@
package com.utun.ble
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertisingSet
import android.bluetooth.le.AdvertisingSetCallback
import android.bluetooth.le.AdvertisingSetParameters
import android.bluetooth.le.BluetoothLeAdvertiser
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.location.LocationManager
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.ParcelUuid
import android.os.PowerManager
import android.os.SystemClock
import android.provider.Settings
import android.util.Base64
import android.util.Log
import java.security.SecureRandom
/**
* Самостоятельный started + bound foreground-сервис. BLE и таймеры принадлежат одному HandlerThread.
* Activity только запускает/останавливает сервис и использует LocalBinder; unbind не останавливает BLE.
* API рассчитан на один процесс приложения. Вход/выход — произвольные байты, без зависимости от GUI и ников.
*/
@SuppressLint("MissingPermission") // Runtime-права проверяются до запуска и повторно в watchdog; отзыв ловится в catch.
class BleBroadcastService : Service() {
companion object {
const val TAG = "UtunBle"
const val MIN_PERIOD_MS = 250L
const val MAX_PERIOD_MS = 3_600_000L
private const val ACTION_START = "com.utun.ble.START"
private const val ACTION_STOP = "com.utun.ble.STOP"
private const val CHANNEL = "utun_ble_transport"
private const val NOTIFICATION = 8412
private const val CALLBACK_TIMEOUT_MS = 10_000L
private const val RETRY_MS = 30_000L // Не попадаем в Android scan start throttling.
/** Постоянный unsigned 64-bit ID установки; в Kotlin хранится в битах Long, 0 зарезервирован. */
@Synchronized fun nodeId(context: Context): Long {
val prefs = context.getSharedPreferences("ble_transport", MODE_PRIVATE)
val existing = prefs.getLong("nodeId", 0)
if (existing != 0L) return existing
val id = generateSequence { SecureRandom().nextLong() }.first { it != 0L }
prefs.edit().putLong("nodeId", id).apply()
return id
}
fun requiredPermissions(): Array<String> = if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT)
} else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
fun hasPermissions(context: Context): Boolean = requiredPermissions().all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
/** Вызывать из видимой Activity после выдачи прав. null payload отключает периодический источник. */
fun start(context: Context, periodicPayload: ByteArray? = null, periodMs: Long = 1000,
targetNodeId: Long = 0, autoConnect: Boolean = false, localNodeId: Long = nodeId(context)) {
require(periodMs in MIN_PERIOD_MS..MAX_PERIOD_MS)
require(periodicPayload == null || periodicPayload.size <= BleProtocol.MAX_PAYLOAD)
require(localNodeId != 0L)
context.startForegroundService(Intent(context, BleBroadcastService::class.java).setAction(ACTION_START)
.putExtra("payload", periodicPayload).putExtra("period", periodMs)
.putExtra("targetNodeId", targetNodeId).putExtra("autoConnect", autoConnect).putExtra("localNodeId", localNodeId))
}
/** Остановка явная; закрытие Activity и отвязка Binder работу не прекращают. */
fun stop(context: Context) {
context.startService(Intent(context, BleBroadcastService::class.java).setAction(ACTION_STOP))
}
}
inner class LocalBinder : Binder() {
/** Неблокирующая запись. QUEUE_FULL означает: повторить после уменьшения txQueued. */
fun send(payload: ByteArray): SendResult {
val result = queues.send(payload)
if (result == SendResult.QUEUED) worker.post { pumpTx() }
return result
}
fun pollReceived(): BleReceived? = queues.pollReceived()
fun drainReceived(limit: Int = 64): List<BleReceived> = queues.drainReceived(limit)
fun snapshot(): BleSnapshot = queues.snapshot()
fun nodeId(): Long = localNodeId
/** Политика входящих запросов в BLE worker. null запрещает входящие, callback не должен блокировать worker. */
fun setConnectionValidator(callback: BleConnectionValidator?) { validator = callback }
/** Заявка по ID, адрес находится из discovery; REQUESTED не означает READY. Повторяет после обрыва до disconnect/stop. */
fun connect(nodeId: Long): ConnectResult {
autoBlocked.remove(nodeId)
return connections.connect(nodeId)
}
/** Отменяет заявку и автоматические попытки к этому ID, закрывает текущее соединение. */
fun disconnect(nodeId: Long) {
autoBlocked.add(nodeId)
connections.disconnect(nodeId)
}
/** Копирует сообщение до 4096 байт в TX готового узла; QUEUE_FULL требует дождаться освобождения очереди. */
fun sendTo(nodeId: Long, payload: ByteArray): NodeSendResult = connections.send(nodeId, payload)
/** Один логический consumer забирает сообщение и возвращает кредит peer; null означает пустой RX. */
fun pollNodeReceived(nodeId: Long): ByteArray? = connections.pollReceived(nodeId)
/** Забирает до limit сообщений, limit > 0. Кредит возвращается асинхронно в BLE worker. */
fun drainNodeReceived(nodeId: Long, limit: Int = 64): List<ByteArray> = connections.drainReceived(nodeId, limit)
/** Неизменяемые снимки известных соединений; sent учитывает только сообщения с удалённым ACK. */
fun connectionSnapshots(): List<BleConnectionStats> = connections.snapshots()
}
private val queues = BleQueues(info = { Log.i(TAG, it) }) { Log.w(TAG, it) }
private val binder = LocalBinder()
private val main = Handler(android.os.Looper.getMainLooper())
private lateinit var thread: HandlerThread
private lateinit var worker: Handler
@Volatile private var localNodeId = 0L
@Volatile private lateinit var connections: BleGattTransport
@Volatile private var validator: BleConnectionValidator? = null
private var autoConnect = false
private val autoBlocked = java.util.concurrent.ConcurrentHashMap.newKeySet<Long>()
private var requested = false
private var generation = 0L
private var scanner: BluetoothLeScanner? = null
private var advertiser: BluetoothLeAdvertiser? = null
private var scanCallback: ScanCallback? = null
private var advertisingCallback: AdvertisingSetCallback? = null
private var advertisingSet: AdvertisingSet? = null
private var active: BleProtocol.Packet? = null
private var repeatPayload: ByteArray? = null
private var repeatPeriod = 1000L
private var txReadyAt = 0L
private var retryAt = 0L
private var wakeLock: PowerManager.WakeLock? = null
private var foreground = false // Только главный поток; защищает от запоздалого обновления notification после stop.
private val preferences by lazy { getSharedPreferences("ble_transport", MODE_PRIVATE) }
private val bluetoothReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
Log.i(TAG, "Adapter state=$state")
worker.post {
if (!requested) return@post
if (state == BluetoothAdapter.STATE_ON) {
retryAt = 0
startRadio()
} else if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) {
closeRadio(adapterUnavailable = true)
queues.setState("Ожидание включения Bluetooth")
}
}
}
}
override fun onCreate() {
super.onCreate()
thread = HandlerThread("utun-ble")
thread.start()
worker = Handler(thread.looper)
localNodeId = nodeId(this)
validator = application as? BleConnectionValidator
connections = BleGattTransport(this, worker, localNodeId, { request -> validator?.validate(request) == true },
{ pumpTx() }, { message -> radioFailed(message) })
val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
if (Build.VERSION.SDK_INT >= 33) registerReceiver(bluetoothReceiver, filter, RECEIVER_EXPORTED)
else registerReceiver(bluetoothReceiver, filter)
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "BLE транспорт", NotificationManager.IMPORTANCE_LOW)
)
Log.i(TAG, "Service created, BLE worker ready")
}
override fun onBind(intent: Intent): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP || (intent == null && !preferences.getBoolean("enabled", false))) {
preferences.edit().putBoolean("enabled", false).apply()
worker.post { stopTransport() }
foreground = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf(startId)
return START_NOT_STICKY
}
try {
val notification = notification("Запуск BLE")
if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
else startForeground(NOTIFICATION, notification)
foreground = true
val payload = if (intent == null) preferences.getString("payload", null)?.let { Base64.decode(it, Base64.NO_WRAP) }
else intent.getByteArrayExtra("payload")
val period = intent?.getLongExtra("period", 1000) ?: preferences.getLong("period", 1000)
val target = intent?.getLongExtra("targetNodeId", 0) ?: preferences.getLong("targetNodeId", 0)
val automatic = intent?.getBooleanExtra("autoConnect", false) ?: preferences.getBoolean("autoConnect", false)
val identity = intent?.getLongExtra("localNodeId", nodeId(this)) ?: nodeId(this)
require(period in MIN_PERIOD_MS..MAX_PERIOD_MS && (payload == null || payload.size <= BleProtocol.MAX_PAYLOAD))
require(identity != 0L)
check(hasPermissions(this)) { "Нет разрешений Bluetooth/геопозиции" }
worker.post {
try {
check(!requested || identity == localNodeId) { "Stop transport before changing node ID" }
if (identity != localNodeId) {
connections.stop()
localNodeId = identity
connections = BleGattTransport(this, worker, localNodeId, { request -> validator?.validate(request) == true },
{ pumpTx() }, { message -> radioFailed(message) })
}
autoConnect = automatic
autoBlocked.clear()
startTransport(payload, period)
if (requested) {
preferences.edit().putBoolean("enabled", true).putLong("period", period).putLong("nodeId", identity)
.putLong("targetNodeId", target).putBoolean("autoConnect", automatic)
.putString("payload", payload?.let { Base64.encodeToString(it, Base64.NO_WRAP) }).apply()
if (target != 0L) connections.connect(target)
}
} catch (error: RuntimeException) { reportError("Transport configuration failed", error) }
}
return START_STICKY
} catch (error: RuntimeException) {
reportError("Service start failed", error)
preferences.edit().putBoolean("enabled", false).apply()
worker.post { stopTransport() }
foreground = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf(startId)
return START_NOT_STICKY
}
}
@SuppressLint("WakelockTimeout") // Длительность теста задаётся явным stop, acquire/release принадлежат сервису.
private fun startTransport(payload: ByteArray?, period: Long) {
repeatPayload = payload?.copyOf()
repeatPeriod = period
worker.removeCallbacks(periodic)
if (!requested) {
requested = true
val session = generateSequence { SecureRandom().nextInt().toLong() and 0xffffffffL }.first { it != 0L }
queues.start(session, localNodeId)
connections.enable()
retryAt = 0
try {
wakeLock = getSystemService(PowerManager::class.java).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:ble-test")
wakeLock?.acquire() // Непрерывный stability test; парный release в stopTransport/onDestroy.
} catch (error: RuntimeException) {
reportError("Wake lock unavailable", error)
stopTransport()
main.post {
preferences.edit().putBoolean("enabled", false).apply()
foreground = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return
}
Log.i(TAG, "Transport started: session=${session.toString(16)} periodMs=$period payloadBytes=${payload?.size}")
startRadio()
worker.post(watchdog)
} else Log.i(TAG, "Periodic source updated: periodMs=$period payloadBytes=${payload?.size}")
if (payload != null) worker.post(periodic)
else if (queues.send(byteArrayOf()) == SendResult.QUEUED) pumpTx()
}
private val periodic = object : Runnable {
override fun run() {
if (!requested || repeatPayload == null) return
if (scanner != null && active == null && queues.peekTx() == null) {
if (queues.send(repeatPayload!!) == SendResult.QUEUED) pumpTx()
} else queues.skipPeriodic()
worker.postDelayed(this, repeatPeriod)
}
}
/** Фильтр manufacturer data сохраняет сканирование при выключенном экране. */
private fun startRadio() {
if (!requested || scanner != null || SystemClock.elapsedRealtime() < retryAt) return
try {
check(hasPermissions(this)) { "Разрешения Bluetooth/геопозиции отозваны" }
val adapter = getSystemService(BluetoothManager::class.java)?.adapter
check(adapter != null) { "Bluetooth адаптер отсутствует" }
if (!adapter.isEnabled) {
queues.setState("Ожидание включения Bluetooth")
return
}
if (Build.VERSION.SDK_INT < 31) {
@Suppress("DEPRECATION")
val locationEnabled = if (Build.VERSION.SDK_INT >= 28) getSystemService(LocationManager::class.java).isLocationEnabled
else Settings.Secure.getInt(contentResolver, Settings.Secure.LOCATION_MODE, 0) != 0
check(locationEnabled) { "Android 8–11: включите геопозицию для BLE scan" }
}
check(adapter.isMultipleAdvertisementSupported) { "Адаптер не поддерживает BLE advertising" }
advertiser = checkNotNull(adapter.bluetoothLeAdvertiser) { "BLE advertiser недоступен" }
scanner = checkNotNull(adapter.bluetoothLeScanner) { "BLE scanner недоступен" }
val token = ++generation
connections.open()
scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
worker.post { if (generation == token) receive(result) }
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
worker.post { if (generation == token) results.forEach { receive(it) } }
}
override fun onScanFailed(errorCode: Int) {
worker.post { if (generation == token) radioFailed("Scan failed: ${scanError(errorCode)} ($errorCode)") }
}
}
val filter = ScanFilter.Builder().setManufacturerData(BleProtocol.COMPANY_ID, BleProtocol.filterPrefix).build()
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).setReportDelay(0).build()
scanner!!.startScan(listOf(filter), settings, scanCallback!!)
queues.setState("BLE: приём запущен")
Log.i(TAG, "Radio started: generation=$token filteredScan=true legacyAdvertising=true")
pumpTx()
} catch (error: RuntimeException) {
radioFailed("Radio start failed", error)
}
}
private fun receive(result: ScanResult) {
val data = result.scanRecord?.getManufacturerSpecificData(BleProtocol.COMPANY_ID) ?: return
val nodeId = BleProtocol.decodeIdentity(result.scanRecord?.getServiceData(ParcelUuid(BleProtocol.IDENTITY_UUID))) ?: return
queues.receive(data, result.rssi, result.timestampNanos / 1_000_000, nodeId)
val packet = BleProtocol.decode(data) ?: return
connections.observe(nodeId, result.device, String(packet.payload, Charsets.UTF_8))
if (autoConnect && !autoBlocked.contains(nodeId) && java.lang.Long.compareUnsigned(localNodeId, nodeId) < 0) {
connections.connect(nodeId)
}
}
/** Один advertising set на весь сеанс: данные обновляются без stop/start и scan throttling. */
private fun pumpTx() {
if (!requested || scanner == null || !connections.ready || active != null) return
val packet = queues.peekTx() ?: return
val wait = txReadyAt - SystemClock.elapsedRealtime()
if (wait > 0) {
worker.removeCallbacks(pump)
worker.postDelayed(pump, wait)
return
}
active = packet
worker.postDelayed(callbackTimeout, CALLBACK_TIMEOUT_MS)
val data = AdvertiseData.Builder().addManufacturerData(BleProtocol.COMPANY_ID, BleProtocol.encode(packet))
.setIncludeDeviceName(false).setIncludeTxPowerLevel(false).build()
try {
if (advertisingSet != null) advertisingSet!!.setAdvertisingData(data)
else {
val token = generation
advertisingCallback = object : AdvertisingSetCallback() {
override fun onAdvertisingSetStarted(set: AdvertisingSet?, txPower: Int, status: Int) {
if (generation != token) return
if (status != ADVERTISE_SUCCESS || set == null) {
radioFailed("Advertising start failed: ${advertisingError(status)} ($status)")
return
}
advertisingSet = set
Log.i(TAG, "Advertising set ready: txPower=$txPower")
completeTx()
}
override fun onAdvertisingDataSet(set: AdvertisingSet, status: Int) {
if (generation != token) return
if (status == ADVERTISE_SUCCESS) completeTx()
else radioFailed("Advertising update failed: ${advertisingError(status)} ($status)")
}
override fun onAdvertisingSetStopped(set: AdvertisingSet) {
if (generation == token) radioFailed("Advertising set stopped unexpectedly")
}
override fun onAdvertisingEnabled(set: AdvertisingSet, enabled: Boolean, status: Int) {
if (generation == token && (!enabled || status != ADVERTISE_SUCCESS)) {
radioFailed("Advertising disabled unexpectedly: enabled=$enabled status=$status")
}
}
}
val parameters = AdvertisingSetParameters.Builder().setLegacyMode(true).setConnectable(true).setScannable(true)
.setInterval(160).setTxPowerLevel(AdvertisingSetParameters.TX_POWER_MEDIUM).build()
val identity = AdvertiseData.Builder().addServiceData(ParcelUuid(BleProtocol.IDENTITY_UUID),
BleProtocol.encodeIdentity(localNodeId)).build()
advertiser!!.startAdvertisingSet(parameters, data, identity, null, null, advertisingCallback!!, worker)
}
} catch (error: RuntimeException) {
radioFailed("Advertising operation failed: seq=${packet.sequence}", error)
}
}
private val pump = Runnable { pumpTx() }
private val callbackTimeout = Runnable { radioFailed("Advertising callback timeout: seq=${active?.sequence}") }
private fun completeTx() {
val packet = active ?: run {
radioFailed("Advertising callback without active packet")
return
}
worker.removeCallbacks(callbackTimeout)
queues.sent(packet.sequence)
active = null
txReadyAt = SystemClock.elapsedRealtime() + MIN_PERIOD_MS
queues.setState("BLE: приём и передача")
if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "TX published: seq=${packet.sequence} bytes=${packet.payload.size}")
pumpTx()
}
private val watchdog = object : Runnable {
override fun run() {
if (!requested) return
if (!hasPermissions(this@BleBroadcastService)) {
radioFailed("Разрешения Bluetooth/геопозиции отозваны")
} else startRadio()
val s = queues.snapshot()
Log.i(TAG, "Stats: state=${s.state} session=${s.session.toString(16)} tx=${s.sent} rx=${s.received} dup=${s.duplicates}" +
" stale=${s.stale} txQ=${s.txQueued} rxQ=${s.rxQueued} rxDrop=${s.rxDropped} rejected=${s.txRejected}" +
" discarded=${s.txDiscarded} invalid=${s.invalid} skipped=${s.periodicSkipped} peers=${s.peers.size} errors=${s.errors}")
connections.snapshots().forEach { Log.i(TAG, "GATT stats: $it") }
main.post {
if (foreground) getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION, notification("${s.state} · TX ${s.sent} · RX ${s.received}"))
}
worker.postDelayed(this, 15_000)
}
}
private fun radioFailed(message: String, error: RuntimeException? = null) {
reportError(message, error)
closeRadio()
retryAt = SystemClock.elapsedRealtime() + RETRY_MS
queues.setState("Ошибка BLE; повтор через 30 с")
if (requested) worker.postDelayed(retry, RETRY_MS)
}
private val retry = Runnable { startRadio() }
private fun reportError(message: String, error: RuntimeException? = null) {
val details = if (error == null) message else "$message: ${error.javaClass.simpleName}: ${error.message}"
queues.error(details)
Log.e(TAG, details, error)
}
/** Сначала инвалидируем callbacks, затем освобождаем радио; TX остаётся для повторной попытки. */
private fun closeRadio(adapterUnavailable: Boolean = false) {
generation++
connections.closeRadio()
worker.removeCallbacks(callbackTimeout)
worker.removeCallbacks(pump)
worker.removeCallbacks(retry)
val oldScan = scanCallback
val oldAdvertising = advertisingCallback
if (adapterUnavailable) {
// При выключении адаптера Android сам очищает scan/advertising clients; stopScan уже запрещён.
Log.i(TAG, "Adapter shutting down; releasing local BLE references")
} else {
try { if (oldScan != null) scanner?.stopScan(oldScan) }
catch (error: RuntimeException) { reportError("Stop scan failed", error) }
try { if (oldAdvertising != null) advertiser?.stopAdvertisingSet(oldAdvertising) }
catch (error: RuntimeException) { reportError("Stop advertising failed", error) }
}
scanner = null
advertiser = null
scanCallback = null
advertisingCallback = null
advertisingSet = null
active = null
txReadyAt = 0
Log.i(TAG, "Radio closed: generation=$generation retainedTx=${queues.snapshot().txQueued}")
}
private fun stopTransport() {
requested = false
worker.removeCallbacks(periodic)
worker.removeCallbacks(watchdog)
closeRadio()
connections.stop()
queues.stop()
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
val s = queues.snapshot()
Log.i(TAG, "Transport stopped: session=${s.session.toString(16)} tx=${s.sent} rx=${s.received}" +
" txDiscarded=${s.txDiscarded} rxQueued=${s.rxQueued} errors=${s.errors}")
}
override fun onDestroy() {
foreground = false
unregisterReceiver(bluetoothReceiver)
worker.post {
stopTransport()
thread.quitSafely()
}
Log.i(TAG, "Service destroyed")
super.onDestroy()
}
private fun notification(text: String): Notification {
val stop = PendingIntent.getService(this, 1, Intent(this, BleBroadcastService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = Notification.Builder(this, CHANNEL).setContentTitle("uTun BLE")
.setContentText(text).setSmallIcon(android.R.drawable.stat_sys_data_bluetooth).setOngoing(true)
.addAction(Notification.Action.Builder(null, "Остановить", stop).build())
packageManager.getLaunchIntentForPackage(packageName)?.let {
builder.setContentIntent(PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
}
return builder.build()
}
private fun advertisingError(code: Int): String = when (code) {
1 -> "DATA_TOO_LARGE"
2 -> "TOO_MANY_ADVERTISERS"
3 -> "ALREADY_STARTED"
4 -> "INTERNAL_ERROR"
5 -> "FEATURE_UNSUPPORTED"
else -> "UNKNOWN"
}
private fun scanError(code: Int): String = when (code) {
1 -> "ALREADY_STARTED"
2 -> "APPLICATION_REGISTRATION_FAILED"
3 -> "INTERNAL_ERROR"
4 -> "FEATURE_UNSUPPORTED"
5 -> "OUT_OF_HARDWARE_RESOURCES"
6 -> "SCANNING_TOO_FREQUENTLY"
else -> "UNKNOWN"
}
}
@@ -1,169 +0,0 @@
package com.utun.ble
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
data class BleConnectionRequest(val localNodeId: Long, val remoteNodeId: Long, val advertisedName: String?)
/** Выполняется в BLE worker, должна быстро вернуть решение; null callback означает отказ. */
fun interface BleConnectionValidator { fun validate(request: BleConnectionRequest): Boolean }
enum class ConnectResult { REQUESTED, STOPPED, INVALID_ID, LIMIT_REACHED }
enum class NodeSendResult { QUEUED, NOT_CONNECTED, QUEUE_FULL, TOO_LARGE }
data class BleConnectionStats(
val nodeId: Long, val state: String, val mtu: Int, val txQueued: Int, val rxQueued: Int,
val sent: Long, val received: Long, val sentBytes: Long, val receivedBytes: Long,
val waitingAck: Boolean, val rxBlocked: Boolean, val discardedTx: Long, val lastError: String
)
/**
* Ограниченные очереди одного соединения и ACK-clocked окно в один фрагмент.
* ACK выдаётся только после резервирования места в RX; при полном RX последний ACK удерживается до чтения.
* Это управление потоком и нагрузкой поверх надёжного ATT, без бессмысленных таймерных ретрансляций.
*/
internal class BleConnectionQueues(private val nodeId: Long, private val capacity: Int = 64) {
companion object {
const val MAX_MESSAGE = 4096
const val HELLO = 1
const val ACCEPT = 2
const val DATA = 3
const val ACK = 4
const val REJECT = 5
private fun buffer(size: Int) = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN)
fun hello(type: Int, source: Long, target: Long): ByteArray = buffer(17).put(type.toByte()).putLong(source).putLong(target).array()
fun ack(sequence: Long, offset: Int): ByteArray = buffer(7).put(ACK.toByte()).putInt(sequence.toInt()).putShort(offset.toShort()).array()
}
private val tx = ArrayDeque<ByteArray>()
private val rx = ArrayDeque<ByteArray>()
private var state = "DISCONNECTED"
private var mtu = 23
private var error = ""
private var txSequence = 1L
private var txOffset = 0
private var waitingOffset: Int? = null
private var rxSequence = 1L
private var assembly: ByteArray? = null
private var rxOffset = 0
private var blocked = false
private var sent = 0L
private var received = 0L
private var sentBytes = 0L
private var receivedBytes = 0L
private var discardedTx = 0L
init { require(capacity > 0) }
@Synchronized fun phase(value: String) { state = value }
@Synchronized fun setMtu(value: Int) { mtu = value.coerceIn(23, 517) }
@Synchronized fun close(reason: String) {
state = "DISCONNECTED"
error = reason
discardedTx += tx.size
tx.clear()
assembly = null
waitingOffset = null
txSequence = 1
rxSequence = 1
txOffset = 0
rxOffset = 0
blocked = false
mtu = 23
}
@Synchronized fun send(payload: ByteArray): NodeSendResult {
if (state != "READY") return NodeSendResult.NOT_CONNECTED
if (payload.size > MAX_MESSAGE) return NodeSendResult.TOO_LARGE
if (tx.size >= capacity) return NodeSendResult.QUEUE_FULL
tx.addLast(payload.copyOf())
return NodeSendResult.QUEUED
}
/** Вызывается только при свободном ATT writer; резервирует единственный in-flight фрагмент. */
@Synchronized fun nextFrame(): ByteArray? {
if (state != "READY" || waitingOffset != null) return null
val message = tx.peekFirst() ?: return null
check(txSequence <= 0xffffffffL) { "TX sequence exhausted; reconnect required" }
val length = minOf(message.size - txOffset, minOf(mtu - 3, 512) - 9)
val frame = buffer(9 + length).put(DATA.toByte()).putInt(txSequence.toInt())
.putShort(txOffset.toShort()).putShort(message.size.toShort()).put(message, txOffset, length).array()
waitingOffset = txOffset + length
return frame
}
@Synchronized fun acceptAck(frame: ByteArray) {
require(frame.size == 7 && frame[0].toInt() == ACK) { "Malformed ACK" }
val b = ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN)
b.get()
val seq = b.int.toLong() and 0xffffffffL
val offset = b.short.toInt() and 0xffff
require(waitingOffset != null && seq == txSequence && offset == waitingOffset) {
"Unexpected ACK seq=$seq offset=$offset expected=$txSequence/$waitingOffset"
}
txOffset = offset
waitingOffset = null
if (offset == checkNotNull(tx.peekFirst()).size) {
sentBytes += tx.removeFirst().size
sent++
txSequence++
txOffset = 0
}
}
/** Возвращает ACK или null, если заполненный RX остановил отправителя; malformed вызывает отказ соединения. */
@Synchronized fun acceptData(frame: ByteArray): ByteArray? {
require(state == "READY" && frame.size in 9..minOf(mtu - 3, 512) && frame[0].toInt() == DATA) { "Invalid DATA frame" }
val b = ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN)
b.get()
val seq = b.int.toLong() and 0xffffffffL
val offset = b.short.toInt() and 0xffff
val total = b.short.toInt() and 0xffff
require(!blocked && seq == rxSequence && offset == rxOffset && total <= MAX_MESSAGE) {
"DATA order/size error seq=$seq offset=$offset total=$total expected=$rxSequence/$rxOffset blocked=$blocked"
}
if (assembly == null) assembly = ByteArray(total)
require(assembly!!.size == total && b.remaining() <= total - offset && (b.hasRemaining() || total == 0)) {
"Invalid DATA fragment length=${b.remaining()} offset=$offset total=$total"
}
val length = b.remaining()
b.get(assembly!!, rxOffset, length)
rxOffset += length
if (rxOffset == total) {
if (rx.size == capacity) {
blocked = true
return null
}
return finishReceive()
}
return ack(seq, rxOffset)
}
private fun finishReceive(): ByteArray {
val result = ack(rxSequence, rxOffset)
val message = checkNotNull(assembly)
rx.addLast(message)
received++
receivedBytes += message.size
assembly = null
rxOffset = 0
rxSequence++
blocked = false
return result
}
/** Worker вызывает после чтения consumer: освобождённое место возвращает отправителю кредит. */
@Synchronized fun releaseCredit(): ByteArray? = if (blocked && rx.size < capacity) finishReceive() else null
@Synchronized fun pollReceived(): ByteArray? = rx.pollFirst()
@Synchronized fun drainReceived(limit: Int): List<ByteArray> {
require(limit > 0)
return List(minOf(limit, rx.size)) { rx.removeFirst() }
}
@Synchronized fun snapshot() = BleConnectionStats(nodeId, state, mtu, tx.size, rx.size, sent, received,
sentBytes, receivedBytes, waitingOffset != null, blocked, discardedTx, error)
}
@@ -1,500 +0,0 @@
package com.utun.ble
import android.annotation.SuppressLint
import android.bluetooth.*
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.SystemClock
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* GATT central/peripheral одного сервиса. Все GATT операции принадлежат worker, API очередей потокобезопасен.
* HELLO(source,target) → callback политики → ACCEPT; до ACCEPT данные запрещены. Bonding не требуется.
* У каждой стороны один ATT writer, у серверных indications — общий writer; ACK имеет приоритет над DATA.
*/
@SuppressLint("MissingPermission")
internal class BleGattTransport(
private val context: Context,
private val worker: Handler,
private val localId: Long,
private val validate: (BleConnectionRequest) -> Boolean,
private val radioReady: () -> Unit,
private val radioError: (String) -> Unit
) {
companion object {
private val SERVICE = UUID.fromString("f61a9010-4abc-45ac-98e1-761df4c13811")
private val PIPE = UUID.fromString("f61a9011-4abc-45ac-98e1-761df4c13811")
private val CCC = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val TAG = BleBroadcastService.TAG
private const val MAX_LINKS = 8
}
private class Link(val device: BluetoothDevice, var nodeId: Long, val outgoing: Boolean) {
var gatt: BluetoothGatt? = null
var pipe: BluetoothGattCharacteristic? = null
var queues: BleConnectionQueues? = null
var subscribed = false
var ready = false
var closed = false
var operation = false
var operationType = 0
var deadline = SystemClock.elapsedRealtime() + 30_000
var operationDeadline = 0L
val control = ArrayDeque<ByteArray>()
}
private data class Discovery(val device: BluetoothDevice, val name: String, val seen: Long)
@Volatile private var enabled = false
@Volatile var ready = false
private set
private val wanted = ConcurrentHashMap.newKeySet<Long>()
private val states = ConcurrentHashMap<Long, BleConnectionQueues>()
private val discoveries = LinkedHashMap<Long, Discovery>()
private val retryAt = HashMap<Long, Long>()
private val byNode = HashMap<Long, Link>()
private val incoming = HashMap<String, Link>()
private var server: BluetoothGattServer? = null
private var serverPipe: BluetoothGattCharacteristic? = null
private var serverWriter: Link? = null
private var generation = 0L
fun enable() { enabled = true }
@Synchronized fun connect(nodeId: Long): ConnectResult {
if (!enabled) return ConnectResult.STOPPED
if (nodeId == 0L || nodeId == localId) return ConnectResult.INVALID_ID
if ((!wanted.contains(nodeId) && wanted.size >= MAX_LINKS) || (!states.containsKey(nodeId) && states.size >= 32)) {
Log.w(TAG, "GATT connect rejected: peer limit node=${BleProtocol.nodeIdText(nodeId)}")
return ConnectResult.LIMIT_REACHED
}
if (!wanted.add(nodeId)) return ConnectResult.REQUESTED
states.computeIfAbsent(nodeId) { BleConnectionQueues(it).also { q -> q.phase("WAIT_DISCOVERY") } }
worker.post { maintain() }
return ConnectResult.REQUESTED
}
fun disconnect(nodeId: Long) {
wanted.remove(nodeId)
worker.post {
byNode[nodeId]?.let { closeLink(it, "Disconnected by API") }
states[nodeId]?.close("Disconnected by API")
}
}
fun send(nodeId: Long, payload: ByteArray): NodeSendResult {
val result = states[nodeId]?.send(payload) ?: NodeSendResult.NOT_CONNECTED
if (result == NodeSendResult.QUEUED) worker.post { pumpAll() }
else Log.w(TAG, "GATT send rejected: node=${BleProtocol.nodeIdText(nodeId)} bytes=${payload.size} reason=$result")
return result
}
fun pollReceived(nodeId: Long): ByteArray? {
val result = states[nodeId]?.pollReceived()
if (result != null) worker.post { returnCredit(nodeId) }
return result
}
fun drainReceived(nodeId: Long, limit: Int): List<ByteArray> {
require(limit > 0)
val result = states[nodeId]?.drainReceived(limit).orEmpty()
if (result.isNotEmpty()) worker.post { returnCredit(nodeId) }
return result
}
fun snapshots(): List<BleConnectionStats> = states.values.map { it.snapshot() }.sortedBy { BleProtocol.nodeIdText(it.nodeId) }
fun observe(nodeId: Long, device: BluetoothDevice, name: String) {
if (nodeId == localId) return
if (!discoveries.containsKey(nodeId) && discoveries.size >= 256) discoveries.remove(discoveries.keys.first())
discoveries.remove(nodeId)
discoveries[nodeId] = Discovery(device, name, SystemClock.elapsedRealtime())
if (wanted.contains(nodeId) && !byNode.containsKey(nodeId)) maintain()
}
fun open() {
check(server == null)
val token = ++generation
val callback = object : BluetoothGattServerCallback() {
private fun dispatch(block: () -> Unit) {
worker.post {
if (generation != token || server == null) return@post
try { block() } catch (e: RuntimeException) {
Log.e(TAG, "GATT server callback failed", e)
radioError("GATT server callback failed: ${e.message}")
}
}
}
override fun onServiceAdded(status: Int, service: BluetoothGattService) = dispatch {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "GATT addService failed: status=$status")
radioError("GATT addService failed: status=$status")
} else {
ready = true
Log.i(TAG, "GATT server ready: node=${BleProtocol.nodeIdText(localId)}")
radioReady()
}
}
override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) = dispatch {
if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
val outgoing = byNode.values.firstOrNull { it.outgoing && it.device.address == device.address }
// Android сообщает GATT server также о собственном central ACL. Это не второй запрос подключения.
if (outgoing?.ready == true) return@dispatch
if (outgoing == null && incoming.size + byNode.values.count { it.outgoing } >= MAX_LINKS) {
Log.w(TAG, "GATT incoming rejected: link limit address=${device.address}")
server?.cancelConnection(device)
} else if (!incoming.containsKey(device.address)) {
incoming[device.address] = Link(device, 0, false)
Log.i(TAG, "GATT incoming transport: address=${device.address}, awaiting HELLO")
}
} else incoming[device.address]?.let { closeLink(it, "GATT disconnected status=$status state=$newState") }
}
override fun onMtuChanged(device: BluetoothDevice, mtu: Int) = dispatch {
val link = incoming[device.address] ?: return@dispatch
link.queues?.setMtu(mtu)
// HELLO может прийти позже MTU callback, поэтому значение сохраняется отдельно для входящего link.
incomingMtu[device.address] = mtu
}
override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor,
preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) = dispatch {
val link = incoming[device.address]
val valid = link != null && descriptor.uuid == CCC && !preparedWrite && offset == 0 &&
value.contentEquals(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE)
if (valid) link!!.subscribed = true
if (responseNeeded) respond(device, requestId, if (valid) BluetoothGatt.GATT_SUCCESS else BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED)
if (!valid) Log.w(TAG, "GATT invalid subscription: address=${device.address} offset=$offset prepared=$preparedWrite")
}
override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic,
preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) = dispatch {
val link = incoming[device.address]
if (link == null || characteristic.uuid != PIPE || preparedWrite || offset != 0 || !responseNeeded) {
if (responseNeeded) respond(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED)
Log.w(TAG, "GATT invalid write: address=${device.address} offset=$offset prepared=$preparedWrite response=$responseNeeded")
if (link != null) closeLink(link, "Invalid ATT write")
} else {
respond(device, requestId, BluetoothGatt.GATT_SUCCESS)
receiveFrame(link, value)
}
}
override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int,
characteristic: BluetoothGattCharacteristic) = dispatch { respond(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED) }
override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int,
descriptor: BluetoothGattDescriptor) = dispatch { respond(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED) }
override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) = dispatch {
respond(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED)
}
override fun onNotificationSent(device: BluetoothDevice, status: Int) = dispatch {
val link = serverWriter
if (link == null || link.device.address != device.address) {
Log.w(TAG, "GATT stale indication completion: address=${device.address} status=$status")
} else {
serverWriter = null
completeOperation(link, status)
}
}
}
server = checkNotNull(context.getSystemService(BluetoothManager::class.java).openGattServer(context, callback))
val service = BluetoothGattService(SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)
serverPipe = BluetoothGattCharacteristic(PIPE,
BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_INDICATE,
BluetoothGattCharacteristic.PERMISSION_WRITE).apply {
addDescriptor(BluetoothGattDescriptor(CCC, BluetoothGattDescriptor.PERMISSION_WRITE))
}
service.addCharacteristic(serverPipe)
check(server!!.addService(service)) { "GATT addService refused" }
worker.post(tick)
}
private val incomingMtu = HashMap<String, Int>()
private fun respond(device: BluetoothDevice, requestId: Int, status: Int) {
try {
if (server?.sendResponse(device, requestId, status, 0, null) != true) {
Log.e(TAG, "GATT sendResponse refused: address=${device.address} request=$requestId status=$status")
}
} catch (e: RuntimeException) { Log.e(TAG, "GATT sendResponse failed", e) }
}
private fun dial(nodeId: Long, discovery: Discovery) {
val link = Link(discovery.device, nodeId, true)
link.queues = states[nodeId]
link.queues?.phase("CONNECTING")
byNode[nodeId] = link
retryAt[nodeId] = SystemClock.elapsedRealtime() + 10_000
Log.i(TAG, "GATT connecting: node=${BleProtocol.nodeIdText(nodeId)} address=${link.device.address}")
try {
link.gatt = link.device.connectGatt(context, false, clientCallback(link), BluetoothDevice.TRANSPORT_LE,
BluetoothDevice.PHY_LE_1M_MASK, worker)
check(link.gatt != null) { "connectGatt returned null" }
} catch (e: RuntimeException) { closeLink(link, "connectGatt failed: ${e.message}") }
}
private fun clientCallback(link: Link) = object : BluetoothGattCallback() {
private fun valid(gatt: BluetoothGatt): Boolean = !link.closed && link.gatt === gatt
private fun dispatch(gatt: BluetoothGatt, block: () -> Unit) {
if (!valid(gatt)) return
try { block() } catch (e: RuntimeException) { closeLink(link, "GATT client callback failed: ${e.message}") }
}
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) = dispatch(gatt) {
Log.i(TAG, "GATT client transport: node=${BleProtocol.nodeIdText(link.nodeId)} status=$status state=$newState")
if (status != BluetoothGatt.GATT_SUCCESS || newState != BluetoothProfile.STATE_CONNECTED) {
closeLink(link, "Client disconnected status=$status state=$newState")
} else if (!gatt.requestMtu(247)) closeLink(link, "requestMtu refused")
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) = dispatch(gatt) {
Log.i(TAG, "GATT MTU: node=${BleProtocol.nodeIdText(link.nodeId)} mtu=$mtu status=$status")
if (status == BluetoothGatt.GATT_SUCCESS) link.queues?.setMtu(mtu)
else Log.w(TAG, "GATT MTU fallback: node=${BleProtocol.nodeIdText(link.nodeId)} status=$status")
if (!gatt.discoverServices()) closeLink(link, "discoverServices refused")
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) = dispatch(gatt) {
Log.i(TAG, "GATT services discovered: node=${BleProtocol.nodeIdText(link.nodeId)} status=$status")
val pipe = gatt.getService(SERVICE)?.getCharacteristic(PIPE)
val ccc = pipe?.getDescriptor(CCC)
if (status != BluetoothGatt.GATT_SUCCESS || pipe == null || ccc == null) {
closeLink(link, "Service discovery failed status=$status pipe=${pipe != null} ccc=${ccc != null}")
return@dispatch
}
link.pipe = pipe
if (!gatt.setCharacteristicNotification(pipe, true)) {
closeLink(link, "setCharacteristicNotification refused")
return@dispatch
}
@Suppress("DEPRECATION")
val submitted = if (Build.VERSION.SDK_INT >= 33) {
gatt.writeDescriptor(ccc, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) == BluetoothStatusCodes.SUCCESS
} else {
ccc.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
gatt.writeDescriptor(ccc)
}
if (!submitted) closeLink(link, "CCC write refused")
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) = dispatch(gatt) {
if (status != BluetoothGatt.GATT_SUCCESS || descriptor.uuid != CCC) {
closeLink(link, "CCC write failed status=$status")
} else {
link.subscribed = true
Log.i(TAG, "GATT subscribed; sending HELLO: node=${BleProtocol.nodeIdText(link.nodeId)}")
link.queues?.phase("VALIDATING")
queueControl(link, BleConnectionQueues.hello(BleConnectionQueues.HELLO, localId, link.nodeId))
}
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (valid(gatt)) completeOperation(link, status)
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (valid(gatt) && characteristic.uuid == PIPE) receiveFrame(link, value)
}
@Deprecated("API < 33 callback")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT < 33 && valid(gatt) && characteristic.uuid == PIPE) receiveFrame(link, characteristic.value.copyOf())
}
}
private fun receiveFrame(link: Link, frame: ByteArray) {
if (link.closed) return
try {
require(frame.isNotEmpty()) { "Empty GATT frame" }
when (frame[0].toInt()) {
BleConnectionQueues.HELLO -> acceptHello(link, frame)
BleConnectionQueues.ACCEPT -> {
require(link.outgoing && !link.ready && identityMatches(frame, link.nodeId, localId)) { "Invalid ACCEPT identity/state" }
markReady(link)
}
BleConnectionQueues.REJECT -> closeLink(link, "Remote validation rejected")
BleConnectionQueues.DATA -> {
require(link.ready) { "DATA before validation" }
val ack = link.queues!!.acceptData(frame)
if (ack != null) queueControl(link, ack)
else Log.i(TAG, "GATT RX backpressure: node=${BleProtocol.nodeIdText(link.nodeId)}")
}
BleConnectionQueues.ACK -> {
require(link.ready) { "ACK before validation" }
link.queues!!.acceptAck(frame)
}
else -> error("Unknown GATT frame type=${frame[0]}")
}
pumpAll()
} catch (e: RuntimeException) { closeLink(link, "Protocol error: ${e.message}") }
}
private fun identityMatches(frame: ByteArray, source: Long, target: Long): Boolean {
if (frame.size != 17) return false
val b = ByteBuffer.wrap(frame, 1, 16).order(ByteOrder.LITTLE_ENDIAN)
return b.long == source && b.long == target
}
private fun acceptHello(link: Link, frame: ByteArray) {
require(!link.outgoing && !link.ready && link.subscribed && frame.size == 17) { "HELLO state/size invalid" }
val remoteId = ByteBuffer.wrap(frame, 1, 8).order(ByteOrder.LITTLE_ENDIAN).long
require(remoteId != 0L && remoteId != localId && identityMatches(frame, remoteId, localId)) { "HELLO node ID mismatch" }
val discovery = discoveries[remoteId]
val request = BleConnectionRequest(localId, remoteId, discovery?.name)
val allowed = try { validate(request) } catch (e: RuntimeException) {
Log.e(TAG, "GATT validation callback failed: remote=${BleProtocol.nodeIdText(remoteId)}", e)
false
}
Log.i(TAG, "GATT validation: local=${BleProtocol.nodeIdText(localId)} remote=${BleProtocol.nodeIdText(remoteId)} accepted=$allowed")
if (!allowed || (!states.containsKey(remoteId) && states.size >= 32)) {
queueControl(link, byteArrayOf(BleConnectionQueues.REJECT.toByte()))
return
}
val existing = byNode[remoteId]
if (existing != null && existing !== link) {
// Одновременный connect с двух сторон: сохраняем направление от меньшего ID к большему.
if (existing.ready || !existing.outgoing || java.lang.Long.compareUnsigned(localId, remoteId) < 0) {
queueControl(link, byteArrayOf(BleConnectionQueues.REJECT.toByte()))
return
}
closeLink(existing, "Simultaneous connect: keep lower-ID initiator")
}
link.nodeId = remoteId
link.queues = states.computeIfAbsent(remoteId) { BleConnectionQueues(it) }
link.queues!!.setMtu(incomingMtu[link.device.address] ?: 23)
byNode[remoteId] = link
markReady(link)
queueControl(link, BleConnectionQueues.hello(BleConnectionQueues.ACCEPT, localId, remoteId))
}
private fun markReady(link: Link) {
if (link.outgoing) {
incoming[link.device.address]?.takeIf { it.nodeId == 0L }?.let { observer ->
observer.closed = true
incoming.remove(link.device.address)
incomingMtu.remove(link.device.address)
Log.i(TAG, "GATT central ACL observer released: address=${link.device.address}")
}
}
link.ready = true
link.queues!!.phase("READY")
Log.i(TAG, "GATT READY: node=${BleProtocol.nodeIdText(link.nodeId)} outgoing=${link.outgoing} mtu=${link.queues!!.snapshot().mtu}")
}
private fun queueControl(link: Link, frame: ByteArray) {
check(link.control.size < 8) { "Control queue overflow" }
link.control.addLast(frame)
pumpAll()
}
private fun returnCredit(nodeId: Long) {
val link = byNode[nodeId] ?: return
link.queues?.releaseCredit()?.let {
Log.i(TAG, "GATT RX credit resumed: node=${BleProtocol.nodeIdText(nodeId)}")
queueControl(link, it)
}
}
private fun pumpAll() {
(byNode.values + incoming.values).distinct().forEach { pump(it) }
}
private fun pump(link: Link) {
if (link.closed || link.operation || !link.subscribed || (!link.outgoing && serverWriter != null)) return
try {
val frame = link.control.pollFirst() ?: link.queues?.nextFrame() ?: return
link.operation = true
link.operationType = frame[0].toInt()
link.operationDeadline = SystemClock.elapsedRealtime() + 20_000
@Suppress("DEPRECATION")
val submitted = if (link.outgoing) {
val gatt = checkNotNull(link.gatt)
val pipe = checkNotNull(link.pipe)
if (Build.VERSION.SDK_INT >= 33) {
gatt.writeCharacteristic(pipe, frame, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == BluetoothStatusCodes.SUCCESS
} else {
pipe.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
pipe.value = frame
gatt.writeCharacteristic(pipe)
}
} else {
serverWriter = link
val pipe = checkNotNull(serverPipe)
if (Build.VERSION.SDK_INT >= 33) {
server!!.notifyCharacteristicChanged(link.device, pipe, true, frame) == BluetoothStatusCodes.SUCCESS
} else {
pipe.value = frame
server!!.notifyCharacteristicChanged(link.device, pipe, true)
}
}
check(submitted) { "GATT writer refused type=${frame[0]} bytes=${frame.size}" }
} catch (e: RuntimeException) { closeLink(link, "GATT send failed: ${e.message}") }
}
private fun completeOperation(link: Link, status: Int) {
if (link.closed) return
if (!link.operation || status != BluetoothGatt.GATT_SUCCESS) {
closeLink(link, "ATT completion failed: active=${link.operation} status=$status")
return
}
val reject = link.operationType == BleConnectionQueues.REJECT
link.operation = false
if (reject) closeLink(link, "Validation rejected")
pumpAll()
}
private val tick = object : Runnable {
override fun run() {
if (server == null) return
maintain()
worker.postDelayed(this, 1000)
}
}
private fun maintain() {
if (!ready) return
val now = SystemClock.elapsedRealtime()
(byNode.values + incoming.values).distinct().toList().forEach {
if ((!it.ready && now >= it.deadline) || (it.operation && now >= it.operationDeadline)) closeLink(it, "GATT operation/handshake timeout")
}
wanted.forEach { nodeId ->
val discovery = discoveries[nodeId]
if (!byNode.containsKey(nodeId) && now >= (retryAt[nodeId] ?: 0) && discovery != null && now - discovery.seen < 30_000 &&
incoming.size + byNode.values.count { it.outgoing } < MAX_LINKS) dial(nodeId, discovery)
}
pumpAll()
}
private fun closeLink(link: Link, reason: String) {
if (link.closed) return
link.closed = true
if (serverWriter === link) serverWriter = null
if (byNode[link.nodeId] === link) byNode.remove(link.nodeId)
if (incoming[link.device.address] === link) incoming.remove(link.device.address)
incomingMtu.remove(link.device.address)
link.queues?.close(reason)
Log.w(TAG, "GATT closed: node=${BleProtocol.nodeIdText(link.nodeId)} outgoing=${link.outgoing} reason=$reason")
try {
if (link.outgoing) link.gatt?.disconnect() else server?.cancelConnection(link.device)
} catch (e: RuntimeException) { Log.e(TAG, "GATT disconnect failed", e) }
try { link.gatt?.close() } catch (e: RuntimeException) { Log.e(TAG, "GATT close failed", e) }
retryAt[link.nodeId] = SystemClock.elapsedRealtime() + 10_000
}
fun closeRadio() {
ready = false
generation++
worker.removeCallbacks(tick)
(byNode.values + incoming.values).distinct().toList().forEach { closeLink(it, "Radio closed") }
try { server?.close() } catch (e: RuntimeException) { Log.e(TAG, "GATT server close failed", e) }
server = null
serverPipe = null
incomingMtu.clear()
discoveries.clear()
serverWriter = null
}
fun stop() {
enabled = false
wanted.clear()
closeRadio()
states.values.forEach { it.close("Transport stopped") }
retryAt.clear()
}
}
@@ -1,48 +0,0 @@
package com.utun.ble
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
/** Legacy BLE datagram: 24 байта manufacturer data; 13 байт доступны приложению. */
object BleProtocol {
const val COMPANY_ID = 0xffff // Только экспериментальное использование, не production Company ID.
const val MAX_PAYLOAD = 13
const val VERSION = 2
val IDENTITY_UUID: UUID = UUID.fromString("f61a9001-4abc-45ac-98e1-761df4c13811")
val filterPrefix: ByteArray get() = byteArrayOf(0x75, 0x54, VERSION.toByte())
data class Packet(val session: Long, val sequence: Long, val payload: ByteArray)
fun nodeIdText(nodeId: Long): String = java.lang.Long.toUnsignedString(nodeId, 16).padStart(16, '0')
/** Постоянный ID в scan response; имя остаётся в manufacturer data основного объявления. */
fun encodeIdentity(nodeId: Long): ByteArray {
require(nodeId != 0L)
return ByteBuffer.allocate(9).order(ByteOrder.LITTLE_ENDIAN).put(VERSION.toByte()).putLong(nodeId).array()
}
fun decodeIdentity(data: ByteArray?): Long? {
if (data == null || data.size != 9 || data[0] != VERSION.toByte()) return null
return ByteBuffer.wrap(data, 1, 8).order(ByteOrder.LITTLE_ENDIAN).long.takeIf { it != 0L }
}
/** Все целые wire-поля — unsigned 32-bit, little endian; sequence начинается с 1. */
fun encode(packet: Packet): ByteArray {
require(packet.session in 1..0xffffffffL && packet.sequence in 1..0xffffffffL)
require(packet.payload.size <= MAX_PAYLOAD)
return ByteBuffer.allocate(11 + packet.payload.size).order(ByteOrder.LITTLE_ENDIAN)
.put(filterPrefix).putInt(packet.session.toInt()).putInt(packet.sequence.toInt()).put(packet.payload).array()
}
/** null означает чужой или повреждённый пакет; вызывающий код учитывает и логирует отказ. */
fun decode(data: ByteArray): Packet? {
if (data.size !in 11..24 || !data.copyOfRange(0, 3).contentEquals(filterPrefix)) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(3)
val session = buffer.int.toLong() and 0xffffffffL
val sequence = buffer.int.toLong() and 0xffffffffL
if (session == 0L || sequence == 0L) return null
return Packet(session, sequence, data.copyOfRange(11, data.size))
}
}
@@ -1,187 +0,0 @@
package com.utun.ble
import java.util.ArrayDeque
import java.util.BitSet
enum class SendResult { QUEUED, STOPPED, QUEUE_FULL, PAYLOAD_TOO_LARGE, SEQUENCE_EXHAUSTED }
/** Копии payload принадлежат получателю; elapsedMs — монотонное время Android, не UTC. */
data class BleReceived(val session: Long, val sequence: Long, val payload: ByteArray, val rssi: Int, val elapsedMs: Long, val nodeId: Long)
data class BlePeerStats(
val session: Long, val payload: ByteArray, val received: Long, val duplicates: Long,
val stale: Long, val lastSequence: Long, val rssi: Int, val lastSeenMs: Long, val nodeId: Long
)
data class BleSnapshot(
val running: Boolean, val state: String, val session: Long, val sent: Long, val received: Long,
val duplicates: Long, val stale: Long, val txQueued: Int, val rxQueued: Int, val txRejected: Long,
val txDiscarded: Long, val rxDropped: Long, val invalid: Long, val periodicSkipped: Long,
val peerEvictions: Long, val errors: Long, val lastError: String, val peers: List<BlePeerStats>, val nodeId: Long
)
/**
* Потокобезопасные ограниченные очереди и статистика. TX отклоняет запись при заполнении (backpressure),
* RX удаляет старейшее сообщение, сохраняя счётчики. Статистика не зависит от чтения RX или жизни Activity.
* warn получает редкие аномалии; все изменения и копирование снимков защищены одним монитором.
*/
internal class BleQueues(
private val txCapacity: Int = 64,
private val rxCapacity: Int = 256,
private val peerCapacity: Int = 256,
private val info: (String) -> Unit = {},
private val warn: (String) -> Unit
) {
private data class Identity(val nodeId: Long, val session: Long)
private class Peer(val session: Long, val nodeId: Long) {
var payload = byteArrayOf()
var received = 0L
var duplicates = 0L
var stale = 0L
var highest = 0L
var rssi = 0
var lastSeen = 0L
val seen = BitSet(256)
}
private val tx = ArrayDeque<BleProtocol.Packet>()
private val rx = ArrayDeque<BleReceived>()
private val peers = LinkedHashMap<Identity, Peer>(16, 0.75f, true)
private var running = false
private var state = "Остановлен"
private var session = 0L
private var localNodeId = 0L
private var sequence = 0L
private var sent = 0L
private var received = 0L
private var duplicates = 0L
private var stale = 0L
private var txRejected = 0L
private var txDiscarded = 0L
private var rxDropped = 0L
private var invalid = 0L
private var periodicSkipped = 0L
private var peerEvictions = 0L
private var errors = 0L
private var lastError = ""
init { require(txCapacity > 0 && rxCapacity > 0 && peerCapacity > 0) }
@Synchronized fun start(newSession: Long, nodeId: Long = newSession) {
require(newSession in 1..0xffffffffL)
require(nodeId != 0L)
localNodeId = nodeId
session = newSession
sequence = 0
running = true
state = "Запуск BLE"
}
@Synchronized fun stop() {
running = false
txDiscarded += tx.size
if (tx.isNotEmpty()) warn("TX shutdown: discarded=${tx.size}")
tx.clear()
state = "Остановлен"
}
@Synchronized fun setState(value: String) { state = value }
@Synchronized fun error(message: String) { errors++; lastError = message }
@Synchronized fun skipPeriodic() { periodicSkipped++ }
@Synchronized fun send(payload: ByteArray): SendResult {
val failure = when {
!running -> SendResult.STOPPED
payload.size > BleProtocol.MAX_PAYLOAD -> SendResult.PAYLOAD_TOO_LARGE
tx.size >= txCapacity -> SendResult.QUEUE_FULL
sequence == 0xffffffffL -> SendResult.SEQUENCE_EXHAUSTED
else -> null
}
if (failure != null) {
txRejected++
warn("TX rejected: reason=$failure bytes=${payload.size} queued=${tx.size}")
return failure
}
tx.addLast(BleProtocol.Packet(session, ++sequence, payload.copyOf()))
return SendResult.QUEUED
}
@Synchronized fun peekTx(): BleProtocol.Packet? = tx.peekFirst()
@Synchronized fun sent(sequence: Long) {
check(tx.peekFirst()?.sequence == sequence)
tx.removeFirst()
sent++
}
/** Сохраняет сообщения с перестановкой до 255 номеров; более старые считаются stale. */
@Synchronized fun receive(data: ByteArray, rssi: Int, elapsedMs: Long, nodeId: Long) {
val packet = BleProtocol.decode(data)
if (packet == null) {
invalid++
if (invalid == 1L || invalid % 100 == 0L) {
warn("RX invalid: bytes=${data.size} count=$invalid hex=${data.take(24).joinToString("") { "%02x".format(it) }}")
}
return
}
if (nodeId == 0L || nodeId == localNodeId) return
val identity = Identity(nodeId, packet.session)
val peer = peers[identity] ?: run {
if (peers.size == peerCapacity) {
val iterator = peers.entries.iterator()
val evicted = iterator.next().key
iterator.remove()
peerEvictions++
warn("RX peer limit: evicted=$evicted count=$peerEvictions")
}
info("RX peer discovered: node=${BleProtocol.nodeIdText(nodeId)} session=${packet.session.toString(16)} seq=${packet.sequence}" +
" payloadHex=${packet.payload.joinToString("") { "%02x".format(it) }} rssi=$rssi")
Peer(packet.session, nodeId).also { peers[identity] = it }
}
peer.rssi = rssi
peer.lastSeen = maxOf(peer.lastSeen, elapsedMs)
if (packet.sequence <= peer.highest - 256) {
peer.stale++
stale++
return
}
if (packet.sequence > peer.highest) {
val advance = packet.sequence - peer.highest
if (advance >= 256) peer.seen.clear()
else for (number in peer.highest + 1..packet.sequence) peer.seen.clear((number % 256).toInt())
peer.highest = packet.sequence
peer.payload = packet.payload.copyOf()
}
val slot = (packet.sequence % 256).toInt()
if (peer.seen[slot]) {
peer.duplicates++
duplicates++
return
}
peer.seen.set(slot)
peer.received++
received++
if (rx.size == rxCapacity) {
rx.removeFirst()
rxDropped++
if (rxDropped == 1L || rxDropped % 100 == 0L) warn("RX overflow: dropped=$rxDropped capacity=$rxCapacity")
}
rx.addLast(BleReceived(packet.session, packet.sequence, packet.payload, rssi, elapsedMs, nodeId))
}
/** Забирает одно сообщение или null; один логический consumer владеет очередью RX. */
@Synchronized fun pollReceived(): BleReceived? = rx.pollFirst()
/** Забирает до limit сообщений; limit должен быть положительным. */
@Synchronized fun drainReceived(limit: Int): List<BleReceived> {
require(limit > 0)
return List(minOf(limit, rx.size)) { rx.removeFirst() }
}
@Synchronized fun snapshot() = BleSnapshot(
running, state, session, sent, received, duplicates, stale, tx.size, rx.size, txRejected, txDiscarded,
rxDropped, invalid, periodicSkipped, peerEvictions, errors, lastError,
peers.values.map { BlePeerStats(it.session, it.payload.copyOf(), it.received, it.duplicates,
it.stale, it.highest, it.rssi, it.lastSeen, it.nodeId) }, localNodeId
)
}
@@ -1,133 +0,0 @@
package com.utun.ble
import org.junit.Assert.*
import org.junit.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
class BleConnectionQueuesTest {
private fun ready(capacity: Int = 64, mtu: Int = 23) = BleConnectionQueues(2, capacity).apply {
phase("READY")
setMtu(mtu)
}
@Test fun identityPreservesEveryUnsigned64Bit() {
for (id in listOf(1L, Long.MAX_VALUE, Long.MIN_VALUE, -1L)) {
assertEquals(id, BleProtocol.decodeIdentity(BleProtocol.encodeIdentity(id)))
assertEquals(16, BleProtocol.nodeIdText(id).length)
val hello = BleConnectionQueues.hello(BleConnectionQueues.HELLO, id, -id)
assertEquals(17, hello.size)
val b = ByteBuffer.wrap(hello, 1, 16).order(ByteOrder.LITTLE_ENDIAN)
assertEquals(id, b.long)
assertEquals(-id, b.long)
}
assertNull(BleProtocol.decodeIdentity(null))
assertNull(BleProtocol.decodeIdentity(byteArrayOf()))
assertNull(BleProtocol.decodeIdentity(BleProtocol.encodeIdentity(1).also { it[0] = 99 }))
assertNull(BleProtocol.decodeIdentity(ByteArray(9).also { it[0] = BleProtocol.VERSION.toByte() }))
}
@Test fun dataWaitsForValidationAndQueueCapacityProvidesBackpressure() {
val q = BleConnectionQueues(2, 1)
assertEquals(NodeSendResult.NOT_CONNECTED, q.send(byteArrayOf(1)))
assertNull(q.nextFrame())
q.phase("READY")
assertEquals(NodeSendResult.TOO_LARGE, q.send(ByteArray(4097)))
val data = byteArrayOf(1)
assertEquals(NodeSendResult.QUEUED, q.send(data))
data[0] = 7
assertEquals(NodeSendResult.QUEUE_FULL, q.send(data))
val frame = q.nextFrame()!!
assertEquals(1.toByte(), frame.last())
assertNull(q.nextFrame())
assertEquals(0L, q.snapshot().sent)
q.acceptAck(BleConnectionQueues.ack(1, 1))
assertEquals(1L, q.snapshot().sent)
assertEquals(0, q.snapshot().txQueued)
}
@Test fun fragmentationReassemblesExactMessagesAtEveryMtuAndSize() {
for (mtu in listOf(23, 64, 247, 517)) for (size in listOf(0, 1, 11, 12, 240, 512, 4096)) {
val sender = ready(mtu = mtu)
val receiver = ready(mtu = mtu)
val payload = ByteArray(size) { (it * 37).toByte() }
sender.send(payload)
var frames = 0
while (sender.snapshot().txQueued > 0) {
val frame = sender.nextFrame()!!
assertTrue(frame.size <= minOf(mtu - 3, 512))
assertNull(sender.nextFrame())
sender.acceptAck(receiver.acceptData(frame)!!)
assertTrue(++frames <= 400)
}
assertArrayEquals(payload, receiver.pollReceived())
assertEquals(size.toLong(), sender.snapshot().sentBytes)
assertEquals(size.toLong(), receiver.snapshot().receivedBytes)
assertNull(receiver.pollReceived())
}
}
@Test fun fullRxHoldsFinalAckUntilConsumerReturnsCredit() {
val sender = ready(capacity = 2)
val receiver = ready(capacity = 1)
sender.send(byteArrayOf(1))
sender.send(byteArrayOf(2))
sender.acceptAck(receiver.acceptData(sender.nextFrame()!!)!!)
assertNull(receiver.acceptData(sender.nextFrame()!!))
assertTrue(receiver.snapshot().rxBlocked)
assertTrue(sender.snapshot().waitingAck)
assertNull(sender.nextFrame())
assertEquals(1L, sender.snapshot().sent)
assertNull(receiver.releaseCredit())
assertArrayEquals(byteArrayOf(1), receiver.pollReceived())
sender.acceptAck(receiver.releaseCredit()!!)
assertFalse(receiver.snapshot().rxBlocked)
assertEquals(2L, sender.snapshot().sent)
assertArrayEquals(byteArrayOf(2), receiver.pollReceived())
}
@Test fun bothDirectionsMakeProgressWithIndependentAckWindows() {
val a = ready(mtu = 64)
val b = ready(mtu = 64)
val first = ByteArray(4096) { it.toByte() }
val second = ByteArray(2048) { (-it).toByte() }
a.send(first)
b.send(second)
repeat(100) {
a.nextFrame()?.let { frame -> a.acceptAck(b.acceptData(frame)!!) }
b.nextFrame()?.let { frame -> b.acceptAck(a.acceptData(frame)!!) }
}
assertArrayEquals(first, b.pollReceived())
assertArrayEquals(second, a.pollReceived())
}
@Test fun protocolRejectsUnsolicitedAcksWrongOffsetsAndOversizedMessages() {
val q = ready()
assertThrows(IllegalArgumentException::class.java) { q.acceptAck(BleConnectionQueues.ack(1, 0)) }
q.send(byteArrayOf(1))
val frame = q.nextFrame()!!
assertThrows(IllegalArgumentException::class.java) { q.acceptAck(BleConnectionQueues.ack(2, 1)) }
assertThrows(IllegalArgumentException::class.java) { q.acceptAck(BleConnectionQueues.ack(1, 0)) }
assertThrows(IllegalArgumentException::class.java) { ready().acceptData(frame.copyOf(8)) }
assertThrows(IllegalArgumentException::class.java) { ready().acceptData(frame.copyOf().also { it[5] = 1 }) }
assertThrows(IllegalArgumentException::class.java) {
ready().acceptData(frame.copyOf().also { it[7] = 1; it[8] = 0x20 })
}
}
@Test fun disconnectDiscardsUnconfirmedTxAndResetsFragmentState() {
val a = ready()
val b = ready()
a.send(ByteArray(4096))
a.acceptAck(b.acceptData(a.nextFrame()!!)!!)
a.close("test disconnect")
b.close("test disconnect")
assertEquals(1L, a.snapshot().discardedTx)
assertEquals(0L, a.snapshot().sent)
a.phase("READY")
b.phase("READY")
a.send(byteArrayOf(7))
a.acceptAck(b.acceptData(a.nextFrame()!!)!!)
assertArrayEquals(byteArrayOf(7), b.pollReceived())
}
}
@@ -1,177 +0,0 @@
package com.utun.ble
import org.junit.Assert.*
import org.junit.Test
import java.util.Collections
import java.util.concurrent.CountDownLatch
import kotlin.concurrent.thread
class BleQueuesTest {
private fun BleQueues.receive(data: ByteArray, rssi: Int, time: Long) =
receive(data, rssi, time, BleProtocol.decode(data)?.session ?: 42)
private fun wire(session: Long = 42, seq: Long = 1, text: String = "Phone") =
BleProtocol.encode(BleProtocol.Packet(session, seq, text.toByteArray(Charsets.UTF_8)))
@Test fun protocolUsesUnsignedFieldsAndExactLegacyBudget() {
val payload = ByteArray(BleProtocol.MAX_PAYLOAD) { it.toByte() }
val wire = BleProtocol.encode(BleProtocol.Packet(0xffffffffL, 0xffffffffL, payload))
assertEquals(24, wire.size)
assertTrue(wire.size + 4 + 3 <= 31) // manufacturer AD + необязательные flags.
assertEquals(0xff.toByte(), wire[3])
val packet = BleProtocol.decode(wire)!!
assertEquals(0xffffffffL, packet.session)
assertEquals(0xffffffffL, packet.sequence)
assertArrayEquals(payload, packet.payload)
}
@Test fun protocolRejectsMalformedPacketsAndVersionMismatch() {
for (size in 0..10) assertNull(BleProtocol.decode(ByteArray(size)))
assertNull(BleProtocol.decode(ByteArray(25)))
assertNull(BleProtocol.decode(wire().also { it[2] = 99 }))
assertNull(BleProtocol.decode(wire().also { it[0] = 0 }))
assertNull(BleProtocol.decode(wire().also { for (i in 3..6) it[i] = 0 }))
assertNull(BleProtocol.decode(wire().also { for (i in 7..10) it[i] = 0 }))
assertThrows(IllegalArgumentException::class.java) {
BleProtocol.encode(BleProtocol.Packet(1, 1, ByteArray(14)))
}
}
@Test fun sendHasBackpressureAndCountsOnlySuccessfulPublication() {
val warnings = mutableListOf<String>()
val queues = BleQueues(txCapacity = 1, warn = warnings::add)
assertEquals(SendResult.STOPPED, queues.send(byteArrayOf(1)))
queues.start(10)
assertEquals(SendResult.PAYLOAD_TOO_LARGE, queues.send(ByteArray(14)))
val payload = byteArrayOf(7)
assertEquals(SendResult.QUEUED, queues.send(payload))
payload[0] = 9
assertEquals(7.toByte(), queues.peekTx()!!.payload[0])
assertEquals(SendResult.QUEUE_FULL, queues.send(byteArrayOf(8)))
assertEquals(0, queues.snapshot().sent)
assertEquals(1, queues.peekTx()!!.sequence)
queues.sent(1)
assertEquals(1, queues.snapshot().sent)
assertEquals(SendResult.QUEUED, queues.send(byteArrayOf(8)))
assertEquals(2, queues.peekTx()!!.sequence)
assertEquals(3, warnings.size)
}
@Test fun duplicatesAndOutOfOrderPacketsDoNotInflateUniqueCount() {
val queues = BleQueues(warn = {})
queues.start(1)
queues.receive(wire(seq = 3), -60, 100)
queues.receive(wire(seq = 3), -65, 200)
queues.receive(wire(seq = 1), -70, 150)
queues.receive(wire(seq = 2), -75, 300)
queues.receive(wire(seq = 1), -80, 400)
val s = queues.snapshot()
assertEquals(3, s.received)
assertEquals(2, s.duplicates)
assertEquals(3, s.peers.single().lastSequence)
assertEquals(400, s.peers.single().lastSeenMs)
assertEquals(listOf(3L, 1L, 2L), queues.drainReceived(10).map { it.sequence })
}
@Test fun windowRejectsOldDuplicatesAndAcceptsUnseenLatePackets() {
val queues = BleQueues(warn = {})
queues.start(1)
for (seq in 1L..300L) queues.receive(wire(seq = seq), -50, seq)
queues.receive(wire(seq = 1), -50, 301)
queues.receive(wire(seq = 45), -50, 302)
queues.receive(wire(seq = 1000), -50, 303)
queues.receive(wire(seq = 999), -50, 304)
assertEquals(302, queues.snapshot().received)
assertEquals(1, queues.snapshot().stale)
assertEquals(1, queues.snapshot().duplicates)
}
@Test fun rxOverflowIsVisibleAndStatisticsSurviveDrainingAndStop() {
val warnings = mutableListOf<String>()
val queues = BleQueues(rxCapacity = 2, warn = warnings::add)
queues.start(1)
for (seq in 1L..3L) queues.receive(wire(seq = seq), -50, seq)
assertEquals(1, queues.snapshot().rxDropped)
assertEquals(2, queues.pollReceived()!!.sequence)
assertEquals(3, queues.pollReceived()!!.sequence)
assertNull(queues.pollReceived())
queues.stop()
assertEquals(3, queues.snapshot().received)
assertEquals(3, queues.snapshot().peers.single().received)
assertEquals(1, warnings.size)
}
@Test fun newSessionsWithSameNicknameAreIndependentAndLocalPacketsAreIgnored() {
val queues = BleQueues(warn = {})
queues.start(1)
queues.receive(wire(session = 1), -50, 1)
queues.receive(wire(session = 2), -50, 1)
queues.receive(wire(session = 3), -50, 1)
assertEquals(2, queues.snapshot().received)
assertEquals(2, queues.snapshot().peers.size)
}
@Test fun peerMemoryIsBoundedAndSnapshotPayloadsAreIndependent() {
val warnings = mutableListOf<String>()
val queues = BleQueues(peerCapacity = 2, warn = warnings::add)
queues.start(1)
queues.receive(wire(session = 2), -50, 1)
queues.receive(wire(session = 3), -50, 2)
queues.receive(wire(session = 2), -50, 3)
queues.receive(wire(session = 4), -50, 4)
val snapshot = queues.snapshot()
assertEquals(setOf(2L, 4L), snapshot.peers.map { it.session }.toSet())
assertEquals(1, snapshot.peerEvictions)
snapshot.peers.first().payload[0] = 0
assertEquals('P'.code.toByte(), queues.snapshot().peers.first().payload[0])
assertEquals(1, warnings.size)
}
@Test fun stopDiscardsPendingTxAndRestartUsesNewSession() {
val warnings = mutableListOf<String>()
val queues = BleQueues(warn = warnings::add)
queues.start(1)
queues.send(byteArrayOf(7))
queues.stop()
assertEquals(1, queues.snapshot().txDiscarded)
assertNull(queues.peekTx())
queues.start(2)
queues.send(byteArrayOf(8))
assertEquals(2, queues.peekTx()!!.session)
assertEquals(1, queues.peekTx()!!.sequence)
assertEquals(1, warnings.size)
}
@Test fun invalidPacketsAndQueueArgumentsAreObservable() {
val warnings = mutableListOf<String>()
val queues = BleQueues(warn = warnings::add)
queues.receive(byteArrayOf(1), -50, 1)
assertEquals(1, queues.snapshot().invalid)
assertEquals(1, warnings.size)
assertThrows(IllegalArgumentException::class.java) { queues.drainReceived(0) }
}
@Test fun simultaneousProducersCannotOverfillTxOrReuseSequenceNumbers() {
val warnings = Collections.synchronizedList(mutableListOf<String>())
val results = Collections.synchronizedList(mutableListOf<SendResult>())
val queues = BleQueues(txCapacity = 64, warn = warnings::add)
queues.start(1)
val gate = CountDownLatch(1)
val workers = List(8) {
thread {
gate.await()
repeat(20) { results.add(queues.send(byteArrayOf(1))) }
}
}
gate.countDown()
workers.forEach { it.join() }
assertEquals(64, results.count { it == SendResult.QUEUED })
assertEquals(96, results.count { it == SendResult.QUEUE_FULL })
val numbers = mutableListOf<Long>()
while (queues.peekTx() != null) {
val seq = queues.peekTx()!!.sequence
numbers.add(seq)
queues.sent(seq)
}
assertEquals((1L..64L).toList(), numbers)
}
}
-5
View File
@@ -1,5 +0,0 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("com.android.library") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
# По умолчанию только сборка и проверки. install <serial> — установка готового APK.
set -euo pipefail
cd "$(dirname "$0")"
./gradlew :ble-transport:testDebugUnitTest :ble-transport:lintDebug :app:assembleDebug :app:lintDebug
if [[ "${1:-}" == install ]]; then
adb -s "${2:?Usage: ./build.sh install DEVICE_SERIAL}" install -r app/build/outputs/apk/debug/app-debug.apk
fi
-3
View File
@@ -1,3 +0,0 @@
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2g
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
# Общая с Android-чатом версия Gradle; wrapper не дублируем.
BLE_PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$BLE_PROJECT_DIR/../chatgui-android/gradlew" -p "$BLE_PROJECT_DIR" "$@"
@@ -1,16 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ble-test-android"
include(":app", ":ble-transport")
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Одновременный запуск Binder/GATT probe на двух телефонах, с полными раздельными логами."""
import argparse
from pathlib import Path
import subprocess
import sys
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--receiver", required=True, help="ADB serial принимающей стороны")
parser.add_argument("--initiator", required=True, help="ADB serial инициатора")
parser.add_argument("--receiver-id", required=True, help="64-bit ID принимающей стороны, hex")
parser.add_argument("--initiator-id", required=True, help="64-bit ID инициатора, hex")
parser.add_argument("--reject", action="store_true", help="Проверить отказ callback вместо duplex")
parser.add_argument("--install", action="store_true", help="Установить готовые app и androidTest APK перед тестом")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.receiver == args.initiator:
parser.error("Нужны два разных устройства")
for value in (args.receiver_id, args.initiator_id):
if not 0 < int(value, 16) <= 0xffffffffffffffff:
parser.error("ID должен быть ненулевым unsigned 64-bit числом")
args.output.mkdir(parents=True, exist_ok=True)
project = Path(__file__).resolve().parents[1]
roles = [("receiver", args.receiver, args.initiator_id, "false"),
("initiator", args.initiator, args.receiver_id, "true")]
if args.install:
for _, serial, _, _ in roles:
for apk in ("app/build/outputs/apk/debug/app-debug.apk",
"app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"):
subprocess.run(["adb", "-s", serial, "install", "-r", str(project / apk)], check=True)
processes = []
files = []
try:
# Оба процесса создаются до ожидания: согласование запуска не зависит от задержек отдельных tool approvals.
for role, serial, peer, initiate in roles:
logfile = args.output / f"{role}.log"
output = logfile.open("w")
files.append(output)
command = ["adb", "-s", serial, "shell", "am", "instrument", "-w", "-e", "peer", peer,
"-e", "initiator", initiate, "-e", "reject", str(args.reject).lower(),
"com.utun.bletest.test/com.utun.bletest.BleApiProbe"]
processes.append((role, subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT), logfile))
print(f"Started {role}: {serial}, peer={peer}", flush=True)
deadline = time.monotonic() + 210
while any(process.poll() is None for _, process, _ in processes):
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for two-device probe")
time.sleep(0.25)
success = True
for role, process, logfile in processes:
content = logfile.read_text()
print(f"{role}:\n{content}", flush=True)
success &= process.returncode == 0 and "result=PASS " in content and "INSTRUMENTATION_CODE: -1" in content
return 0 if success else 1
finally:
for _, process, _ in processes:
if process.poll() is None:
process.terminate()
process.wait(timeout=10)
for output in files:
output.close()
if __name__ == "__main__":
sys.exit(main())
+6 -3
View File
@@ -131,6 +131,8 @@ static int attach(struct ble_etcp_link* c, const uint8_t* key) {
struct ble_etcp* d = c->owner;
if (!enabled(d)) { fail(c, "ETCP over BLE disabled while UTUN is active"); return -1; }
if (sc_derive_node_id_from_pubkey(key) != c->node_id) { fail(c, "public key does not match BLE node ID"); return -1; }
int outgoing;
if (ble_get_outgoing(d->radio, c->id, &outgoing)) { fail(c, "GATT direction unavailable"); return -1; }
struct ETCP_CONN* conn = instance_find_conn(d->instance, c->node_id);
int created = 0;
if (conn && (conn->close_requested || conn->state == 2)) { fail(c, "ETCP connection is closing"); return -1; }
@@ -144,15 +146,16 @@ static int attach(struct ble_etcp_link* c, const uint8_t* key) {
} else if (memcmp(conn->crypto_ctx.peer_public_key, key, SC_PUBKEY_SIZE)) {
fail(c, "public key differs from existing ETCP peer"); return -1;
}
struct packet_link_driver api = {c, d->ua, "ble-gatt", bind_link, state, send_packet, enable_rx, stop_link, destroy_link};
struct packet_link_driver api = {c, d->ua, "BLE", bind_link, state, send_packet, enable_rx, stop_link, destroy_link,
outgoing ? PACKET_LINK_OUTGOING : PACKET_LINK_INCOMING};
c->link = etcp_link_add(conn, &api);
if (!c->link) {
if (created) etcp_connection_close(conn);
fail(c, "ETCP driver attach failed"); return -1;
}
c->key_received = 1;
DEBUG_INFO(CAT, "BLE ETCP attached link=%llu peer=%016llx max_packet=%d tx_limit=1",
(unsigned long long)c->id, (unsigned long long)c->node_id, BLE_FRAME_MAX);
DEBUG_INFO(CAT, "BLE ETCP attached link=%llu peer=%016llx outgoing=%d max_packet=%d tx_limit=1",
(unsigned long long)c->id, (unsigned long long)c->node_id, outgoing, BLE_FRAME_MAX);
return 0;
}
static void service_link(struct ble_etcp_link* c) {
@@ -6,6 +6,7 @@
#include "../../../lib/ble/ble_backend.h"
#include "etcp.h"
#include "etcp_api.h"
#include "etcp_link_io.h"
#include "topo_node_sqlite.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/mem.h"
@@ -320,6 +321,14 @@ int main(void) {
ble_backend_broadcast(backends[0]->owner, &ep, bytes, len, -30);
assert(ble_etcp_neighbors(endpoints[0].driver, neighbors, 2) == 1 && neighbors[0].rssi == -30);
assert(!ble_etcp_connect(endpoints[0].driver, endpoints[1].inst.node_id)); wait_ready();
for (int i = 0; i < 2; i++) {
struct etcp_link_snapshot snapshot;
assert(!etcp_link_get_snapshot(endpoints[i].conn->links, &snapshot));
assert(!strcmp(snapshot.protocol, "BLE") && !snapshot.has_ip && snapshot.state == 3 && snapshot.status == 1);
assert(snapshot.direction == (i ? PACKET_LINK_INCOMING : PACKET_LINK_OUTGOING));
assert(!snapshot.local.family && !snapshot.remote.family && !snapshot.socket_name[0]);
assert(!endpoints[i].conn->links->is_server);
}
assert(!ble_etcp_connect(endpoints[0].driver, endpoints[1].inst.node_id)); /* Не дублируем GATT. */
for (unsigned n = 0; n < 4; n++) {
hold = 1; exchange(n); drain();
@@ -90,10 +90,13 @@ private fun natProfileLabelRes(mapping: Int, inbound: Int, source: Int): Int = w
else -> R.string.address_nat_unknown
}
enum class LinkDirection { UNKNOWN, OUTGOING, INCOMING }
data class LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAddr: String = "", val localPort: Int = 0,
val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0,
val linkState: Int = 0, val linkStatus: Int = 0,
val isServer: Boolean = false, val socketName: String = "", val protocol: String = "") {
val direction: LinkDirection = LinkDirection.UNKNOWN, val hasIp: Boolean = false,
val socketName: String = "", val protocol: String = "") {
val stateLabel: String get() = AppLanguage.text(when (linkState) {
3 -> if (linkStatus == 1) R.string.connection_up else R.string.connection_down
1, 2 -> R.string.connection_connecting
@@ -489,6 +492,8 @@ class ChatRepository(private val reportFailure: (String) -> Unit = { LogManager.
if (arr != null) {
for (i in 0 until arr.length()) {
val a = arr.getJSONObject(i)
val direction = a.getInt("di")
require(direction in LinkDirection.entries.indices) { "Invalid link direction=$direction" }
links.add(LinkStatus(
linkId = a.optInt("id", 0),
localFamily = a.optInt("lf", 0),
@@ -499,7 +504,8 @@ class ChatRepository(private val reportFailure: (String) -> Unit = { LogManager.
remotePort = a.optInt("rp", 0),
linkState = a.optInt("st", 0),
linkStatus = a.optInt("ls", 0),
isServer = a.optInt("is", 0) != 0,
direction = LinkDirection.entries[direction],
hasIp = a.getBoolean("ip"),
socketName = a.optString("sn", ""),
protocol = a.optString("pt", "")
))
@@ -45,7 +45,7 @@ import com.utun.chat.data.Channel
import com.utun.chat.data.ChatMember
import com.utun.chat.data.MemberDetail
import com.utun.chat.data.MemberLinks
import com.utun.chat.data.LinkStatus
import com.utun.chat.data.LinkDirection
import com.utun.chat.data.LocalSocketState
import com.utun.chat.ui.components.LocalSocketNatInfo
import com.utun.chat.ui.components.PortableRadioIcon
@@ -493,28 +493,36 @@ private fun MemberDetailCard(
Text(stringResource(R.string.connections), fontSize = 13.sp, fontWeight = FontWeight.Medium)
memberLinks.links.forEachIndexed { idx, lk ->
val dcol = if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF81C784) else Color(0xFFFFB4AB)
val arrow = if (lk.isServer) "◀" else "▶"
val arrowColor = if (lk.isServer) Color(0xFFFF9800) else Color(0xFF79B8FF)
val arrow = when (lk.direction) {
LinkDirection.INCOMING -> "◀"
LinkDirection.OUTGOING -> "▶"
LinkDirection.UNKNOWN -> ""
}
val arrowColor = if (lk.direction == LinkDirection.INCOMING) Color(0xFFFF9800) else Color(0xFF79B8FF)
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(dcol))
Spacer(Modifier.width(6.dp))
Text(lk.protocol, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(6.dp))
Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(4.dp))
Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp)
Spacer(Modifier.width(4.dp))
Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
if (lk.hasIp) {
Spacer(Modifier.width(6.dp))
Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f), textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
}
if (arrow.isNotEmpty()) {
Spacer(Modifier.width(4.dp))
Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp)
}
if (lk.hasIp) {
Spacer(Modifier.width(4.dp))
Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}",
modifier = Modifier.weight(1f), textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
}
}
val cleanSn = com.utun.chat.data.cleanSocketName(lk.socketName)
if (cleanSn.isNotEmpty()) {
@@ -35,7 +35,7 @@ class ChatRepositorySnapshotTest {
}
@Test fun brokenLinkSnapshotDoesNotTurnAConnectedPeerIntoDisconnected() {
val row = """{"cn":true,"in":true,"lu":1,"lk":[{"id":9,"st":3,"sn":"Wi-Fi 😀"}]}"""
val row = """{"cn":true,"in":true,"lu":1,"lk":[{"id":9,"di":1,"ip":true,"st":3,"sn":"Wi-Fi 😀"}]}"""
val connected = repo.parseMemberLinks(row)!!
assertTrue(connected.connected)
assertEquals("Wi-Fi 😀", connected.links.single().socketName)
@@ -45,4 +45,26 @@ class ChatRepositorySnapshotTest {
assertEquals(MemberLinks(), repo.parseMemberLinks("""{"cn":false}"""))
assertEquals(connected, repo.parseMemberLinks(row))
}
@Test fun bleSnapshotPreservesDirectionAndSeparatesGattFromEtcpReadiness() {
for ((wire, direction) in LinkDirection.entries.withIndex()) {
val row = """{"cn":true,"lk":[{"pt":"BLE","di":$wire,"ip":false,"st":1,"ls":0}]}"""
val link = repo.parseMemberLinks(row)!!.links.single()
assertEquals("BLE", link.protocol); assertEquals(direction, link.direction)
assertFalse(link.hasIp); assertEquals(1, link.linkState); assertEquals(0, link.linkStatus)
assertEquals("", link.localAddr); assertEquals("", link.remoteAddr)
}
assertNull(repo.parseMemberLinks("""{"cn":true,"lk":[{"di":3,"ip":false}]}"""))
assertTrue(errors.single().contains("Invalid link direction"))
}
@Test fun mixedBleAndIpLinksKeepTheirOwnTransportAndEndpoints() {
val json = """{"cn":true,"lk":[
{"pt":"BLE","di":2,"ip":false,"st":3,"ls":1},
{"pt":"TCP+REALITY","di":1,"ip":true,"la":"::1","lp":1234,"ra":"::2","rp":443,"st":3,"ls":1}
]}"""
val links = repo.parseMemberLinks(json)!!.links
assertEquals(2, links.size); assertFalse(links[0].hasIp); assertTrue(links[1].hasIp)
assertEquals("TCP+REALITY", links[1].protocol); assertEquals("::2", links[1].remoteAddr); assertEquals(443, links[1].remotePort)
}
}
@@ -8,6 +8,7 @@
*/
#include "android_jni_bridge.h"
#include "android_udp_log.h"
#include "../../../src/transport_layer/etcp_link_io.h"
#include "../libutun_lite/utun_config_api.h"
#include "../../../src/chat/invite_link.h"
#include "../libutun_lite/instance_lite.h"
@@ -1058,20 +1059,24 @@ char* utun_bridge_get_member_links_json(uint64_t node_id) {
connection->initialized ? "true" : "false", connection->links_up) < 0) goto failed;
unsigned count = 0;
for (struct ETCP_LINK* link = connection->links; link; link = link->next) {
const struct sockaddr_storage* local = NULL;
if (link->conn) local = link->conn->interface_addr.ss_family ? &link->conn->interface_addr : &link->conn->local_addr;
else if (link->is_tcp && link->tcp_link) local = stcp_link_get_local_addr(link->tcp_link);
char local_text[INET6_ADDRSTRLEN], remote_text[INET6_ADDRSTRLEN];
int local_family, local_port, remote_family, remote_port;
if (bridge_address_parts(local, local_text, sizeof(local_text), &local_family, &local_port) ||
bridge_address_parts(&link->remote_addr, remote_text, sizeof(remote_text), &remote_family, &remote_port)) goto failed;
int state = link->link_state, status = link->link_status;
if (link->is_tcp && link->tcp_link && stcp_link_is_ready(link->tcp_link)) { state = 3; status = 1; }
if (strbuf_addf(&out, "%s{\"id\":%d,\"pt\":\"%s\",\"lf\":%d,\"la\":\"%s\",\"lp\":%d,"
"\"rf\":%d,\"ra\":\"%s\",\"rp\":%d,\"st\":%d,\"ls\":%d,\"is\":%d,\"sn\":",
count++ ? "," : "", link->local_link_id, link->reality_set ? "TCP+REALITY" : link->is_tcp ? "TCP" : "UDP",
local_family, local_text, local_port, remote_family, remote_text, remote_port, state, status, link->is_server) < 0 ||
strbuf_add_json_string(&out, link->conn ? link->conn->name : "") < 0 || strbuf_addf(&out, "}") < 0) goto failed;
struct etcp_link_snapshot snapshot;
if (etcp_link_get_snapshot(link, &snapshot)) goto failed;
char local_text[INET6_ADDRSTRLEN] = {0}, remote_text[INET6_ADDRSTRLEN] = {0};
const struct etcp_link_address* addresses[] = {&snapshot.local, &snapshot.remote};
char* texts[] = {local_text, remote_text};
for (int i = 0; i < 2; i++) {
int family = addresses[i]->family == 4 ? AF_INET : AF_INET6;
if (addresses[i]->family && !inet_ntop(family, addresses[i]->bytes, texts[i], INET6_ADDRSTRLEN)) {
bridge_log(BLEV_ERROR, "Member links snapshot: inet_ntop failed errno=%d: %s", errno, strerror(errno)); goto failed;
}
}
if (strbuf_addf(&out, "%s{\"id\":%d,\"pt\":", count++ ? "," : "", snapshot.id) < 0 ||
strbuf_add_json_string(&out, snapshot.protocol) < 0 ||
strbuf_addf(&out, ",\"di\":%d,\"ip\":%s,\"lf\":%d,\"la\":\"%s\",\"lp\":%d,"
"\"rf\":%d,\"ra\":\"%s\",\"rp\":%d,\"st\":%d,\"ls\":%d,\"sn\":",
snapshot.direction, snapshot.has_ip ? "true" : "false", snapshot.local.family, local_text, snapshot.local.port,
snapshot.remote.family, remote_text, snapshot.remote.port, snapshot.state, snapshot.status) < 0 ||
strbuf_add_json_string(&out, snapshot.socket_name) < 0 || strbuf_addf(&out, "}") < 0) goto failed;
}
if (strbuf_addf(&out, "]}") < 0) goto failed;
bridge_log(BLEV_DEBUG, "Member links snapshot node=%016llx count=%u", (unsigned long long)node_id, count);
+12 -11
View File
@@ -371,12 +371,12 @@ void AccountList::onMemberDetailData(const uint8_t* data, int len) {
for (int i = 0; i < view.link_count; i++) {
const uint8_t* p = view.links + i * CHAT_MEMBER_DETAIL_LINK_SIZE;
MemberLinkSnap lk{};
lk.isTcp = p[0] != 0; lk.linkState = p[1]; lk.linkStatus = p[2]; lk.tcpReady = p[3] != 0; lk.isServer = p[4] != 0;
lk.localFamily = p[5]; lk.localAddr = QByteArray((const char*)p + 6, 16);
lk.localPort = (uint16_t)p[22] | ((uint16_t)p[23] << 8);
lk.family = p[24]; lk.addr = QByteArray((const char*)p + 25, 16);
lk.port = (uint16_t)p[41] | ((uint16_t)p[42] << 8);
memcpy(lk.sockName, p + 43, 20); lk.isReality = p[63] != 0; links.append(lk);
lk.direction = p[0]; lk.linkState = p[1]; lk.linkStatus = p[2]; lk.hasIp = p[3] != 0;
lk.localFamily = p[4]; lk.localAddr = QByteArray((const char*)p + 5, 16);
lk.localPort = (uint16_t)p[21] | ((uint16_t)p[22] << 8);
lk.family = p[23]; lk.addr = QByteArray((const char*)p + 24, 16);
lk.port = (uint16_t)p[40] | ((uint16_t)p[41] << 8);
memcpy(lk.sockName, p + 42, 20); lk.protocol = QString::fromUtf8((const char*)p + 62); links.append(lk);
}
QVector<MemberSockSnap> socks;
for (int i = 0; i < view.sock_count; i++) {
@@ -458,12 +458,13 @@ void AccountList::updateDetailDisplay() {
for (const auto& lk : m_snapLinks) {
bool up = (lk.linkState == 3 && lk.linkStatus == 1);
QString c = up ? "#4CAF50" : "#E53935";
lines.append(QString("<span style='color:%1;'>●</span> %2 %3:%4 %5 %6:%7")
.arg(c)
.arg(lk.isReality ? "TCP+REALITY" : (lk.isTcp ? "TCP" : "UDP"))
QString arrow = lk.direction == 2 ? "◀" : lk.direction == 1 ? "▶" : "";
QString description = lk.protocol.toHtmlEscaped();
if (lk.hasIp) description += QString(" %1:%2 %3 %4:%5")
.arg(addrStr(lk.localAddr, lk.localFamily)).arg(lk.localPort)
.arg(lk.isServer ? "\u25c0" : "\u25b6")
.arg(addrStr(lk.addr, lk.family)).arg(lk.port));
.arg(arrow).arg(addrStr(lk.addr, lk.family)).arg(lk.port);
else if (!arrow.isEmpty()) description += " " + arrow;
lines.append(QString("<span style='color:%1;'>●</span> %2").arg(c, description));
if (lk.sockName[0])
lines.append(QString(" %1").arg(QString::fromUtf8(lk.sockName).toHtmlEscaped()));
}
+3 -1
View File
@@ -35,7 +35,9 @@ struct DetailAddress {
};
struct MemberLinkSnap {
bool isTcp = false, tcpReady = false, isServer = false, isReality = false; // транспорт, TCP-ready, входящее направление, камуфляж
int direction = 0; // 0=неизвестно, 1=исходящее, 2=входящее физическое подключение
bool hasIp = false; // IP-путь имеет адреса/порты; внешний пакетный канал — нет
QString protocol; // подпись транспорта из снимка
int linkState = 0, linkStatus = 0; // физическое состояние ETCP, не online в группе
int localFamily = 0, family = 0; // 0=не задан, иначе 4/6
QByteArray localAddr, addr; // собственные 16-байтовые адреса из wire
+23 -3
View File
@@ -253,14 +253,34 @@ private slots:
auto* data = reinterpret_cast<uint8_t*>(bytes.data()); data[29] = 1;
auto* link = data + CHAT_MEMBER_DETAIL_HEADER_SIZE; memset(link, 0, CHAT_MEMBER_DETAIL_LINK_SIZE);
link[0] = 1; link[1] = 3; link[2] = 1; link[3] = 1;
link[5] = 6; link[21] = 1; link[22] = 0x35; link[23] = 5;
link[24] = 6; link[40] = 2; link[41] = 0xbb; link[42] = 1;
strcpy(reinterpret_cast<char*>(link + 43), "<sock&>");
link[4] = 6; link[20] = 1; link[21] = 0x35; link[22] = 5;
link[23] = 6; link[39] = 2; link[40] = 0xbb; link[41] = 1;
strcpy(reinterpret_cast<char*>(link + 42), "<sock&>");
strcpy(reinterpret_cast<char*>(link + 62), "TCP+REALITY");
deliver(bytes);
QVERIFY(body(panel).contains("[::1]:1333")); QVERIFY(body(panel).contains("[::2]:443"));
QVERIFY(body(panel).contains("<sock&>"));
}
void bleHasNoIpPortsAndUsesPhysicalDirection() {
for (int direction = 0; direction < 3; direction++) {
AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0));
auto bytes = reply(requests.back()); bytes.resize(CHAT_MEMBER_DETAIL_HEADER_SIZE + CHAT_MEMBER_DETAIL_LINK_SIZE);
auto* data = reinterpret_cast<uint8_t*>(bytes.data()); data[29] = 1;
auto* link = data + CHAT_MEMBER_DETAIL_HEADER_SIZE; memset(link, 0, CHAT_MEMBER_DETAIL_LINK_SIZE);
link[0] = direction; link[1] = 3; link[2] = 1; strcpy(reinterpret_cast<char*>(link + 62), "BLE");
deliver(bytes);
const QString text = body(panel);
QVERIFY(text.contains("BLE")); QVERIFY(!text.contains("UDP")); QVERIFY(!text.contains(":0"));
QCOMPARE(text.contains("▶"), direction == 1); QCOMPARE(text.contains("◀"), direction == 2);
// Неверное направление или имя в пакете не заменяет предыдущую карточку.
QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick"));
auto invalid = bytes;
auto* header = reinterpret_cast<uint8_t*>(invalid.data()); mf_put64(header, requests.back().request_id);
header[CHAT_MEMBER_DETAIL_HEADER_SIZE] = 3; deliver(invalid); QCOMPARE(body(panel), text);
}
}
void contextWithoutDatabaseUsesSnapshotAndOwnsConnectRequest() {
AccountList panel; panel.resize(300, 600); panel.show(); fill(panel, "123", row(7));
QSignalSpy calls(&panel, &AccountList::callRequested);
-7
View File
@@ -1,7 +0,0 @@
build/
.gradle/
*.apk
*.iml
.idea/
local.properties
.cxx/
@@ -1,49 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.utun.lightsout"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
defaultConfig {
applicationId = "com.utun.lightsout"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.5"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.activity:activity-compose:1.8.2")
}
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.LightsOut">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,77 +0,0 @@
package com.utun.lightsout
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue
import kotlin.random.Random
// Логика игры «Выключить свет»: хранит истинные состояния клеток (real, 0/1),
// отображаемое значение каждой клетки — XOR её самой с четырьмя соседями.
// Поле хранится как Compose-наблюдаемый список, чтобы клики перерисовывали доску.
class LightsOutGame(initialSize: Int = 5) {
var size: Int = initialSize
private set
val real = mutableStateListOf<Int>()
var moves by mutableIntStateOf(0)
private set
init {
newGame(initialSize)
}
// Новая партия: сброс поля и случайная решаемая позиция.
fun newGame(size: Int) {
this.size = size
real.clear()
repeat(size * size) { real.add(0) }
moves = 0
scramble()
}
// Переключает клетку, инкрементирует счётчик, возвращает true, если решено.
fun toggle(row: Int, col: Int): Boolean {
val idx = row * size + col
real[idx] = real[idx] xor 1
moves++
return isSolved()
}
// Отображаемое значение: XOR клетки с четырьмя соседями (с учётом границ).
fun display(row: Int, col: Int): Int {
var v = real[row * size + col]
if (row > 0) v = v xor real[(row - 1) * size + col]
if (row < size - 1) v = v xor real[(row + 1) * size + col]
if (col > 0) v = v xor real[row * size + (col - 1)]
if (col < size - 1) v = v xor real[row * size + (col + 1)]
return v
}
// Истинное состояние клетки (для подсказки).
fun isReallyOff(row: Int, col: Int): Boolean = real[row * size + col] == 0
// Победа — все отображаемые клетки погашены.
fun isSolved(): Boolean {
for (r in 0 until size) {
for (c in 0 until size) {
if (display(r, c) != 0) return false
}
}
return true
}
// Позиция строится случайными одиночными переключениями из нулевого состояния —
// обратная последовательность ходов гарантированно решает её.
private fun scramble() {
do {
for (i in real.indices) real[i] = 0
val scrambles = size * size * 3
repeat(scrambles) {
val idx = Random.nextInt(size * size)
real[idx] = real[idx] xor 1
}
} while (isSolved())
}
}
@@ -1,206 +0,0 @@
package com.utun.lightsout
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.dp
import kotlin.math.roundToInt
private val OnColor = Color(0xFFFFD65C) // «включена» — тёплый жёлтый
private val OffColor = Color(0xFF2C3038) // «выключена» — тёмно-серый
private val HintColor = Color(0xFF78DC78) // подсказка — зелёная точка
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
LightsOutApp()
}
}
}
}
@Composable
fun LightsOutApp() {
var game by remember { mutableStateOf<LightsOutGame?>(null) }
var hintEnabled by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(true) }
var solved by remember { mutableStateOf(false) }
// Диалог выбора размера: при старте и по кнопке «Новая игра».
if (showDialog || game == null) {
val current = game?.size ?: 5
var selected by remember(current) { mutableIntStateOf(current) }
val isInitial = game == null
AlertDialog(
onDismissRequest = { if (!isInitial) showDialog = false },
title = { Text(if (isInitial) "Выключить свет" else "Новая игра") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"$selected × $selected",
style = MaterialTheme.typography.headlineMedium
)
Slider(
value = selected.toFloat(),
onValueChange = { selected = it.roundToInt() },
valueRange = 3f..8f,
steps = 4
)
Text(
"Размер поля",
style = MaterialTheme.typography.bodyMedium
)
}
},
confirmButton = {
TextButton(onClick = {
game = LightsOutGame(selected)
hintEnabled = false
solved = false
showDialog = false
}) {
Text(if (isInitial) "Играть" else "Начать")
}
},
dismissButton = {
if (!isInitial) {
TextButton(onClick = { showDialog = false }) { Text("Отмена") }
}
}
)
}
game?.let { g ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Ходы: ${g.moves}", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
Board(
game = g,
hintEnabled = hintEnabled,
onCellTap = { r, c ->
if (g.toggle(r, c)) {
solved = true
}
}
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = { hintEnabled = !hintEnabled }) {
Text(if (hintEnabled) "Скрыть подсказку" else "Подсказка")
}
Button(onClick = { showDialog = true }) {
Text("Новая игра")
}
}
}
}
if (solved) {
val g = game ?: return@LightsOutApp
AlertDialog(
onDismissRequest = { solved = false },
title = { Text("Победа!") },
text = { Text("Свет выключен за ${g.moves} ходов.") },
confirmButton = {
Button(onClick = { showDialog = true }) { Text("Новая игра") }
},
dismissButton = {
TextButton(onClick = { solved = false }) { Text("Ок") }
}
)
}
}
@Composable
private fun Board(
game: LightsOutGame,
hintEnabled: Boolean,
onCellTap: (Int, Int) -> Unit
) {
val n = game.size
Column(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
for (r in 0 until n) {
Row(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
for (c in 0 until n) {
val on = game.display(r, c) != 0
val hint = hintEnabled && game.isReallyOff(r, c)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(6.dp))
.background(if (on) OnColor else OffColor)
.border(1.dp, Color(0xFF14161A), RoundedCornerShape(6.dp))
.clickable { onCellTap(r, c) },
contentAlignment = Alignment.Center
) {
if (hint) {
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(HintColor)
)
}
}
}
}
}
}
}
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Выключить свет</string>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LightsOut" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
-4
View File
@@ -1,4 +0,0 @@
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}
-49
View File
@@ -1,49 +0,0 @@
#!/bin/bash
# Сборка и прошивка игры «Выключить свет» (Android).
# Использование:
# ./build.sh # clean + сборка + установка на все подключённые устройства
# ./build.sh noinstall # только сборка, без прошивки
# ./build.sh <SN> # clean + сборка + прошивка на конкретный девайс
set -eo pipefail
cd "$(dirname "$0")"
export ANDROID_HOME="${ANDROID_HOME:-/home/user/android}"
APK=app/build/outputs/apk/debug/app-debug.apk
echo "Building..."
TMP=$(mktemp)
./gradlew clean > /dev/null 2>&1
if ! ./gradlew assembleDebug > "$TMP" 2>&1; then
cat "$TMP"
rm -f "$TMP"
exit 1
fi
rm -f "$TMP"
echo "Build OK"
[ "$1" = "noinstall" ] && exit 0
if [ -n "$1" ]; then
DEVICES=("$1")
else
mapfile -t DEVICES < <(adb devices | awk 'NR>1 && $2=="device"{print $1}')
fi
if [ ${#DEVICES[@]} -eq 0 ]; then
echo "No devices connected" >&2
exit 1
fi
echo "Devices (${#DEVICES[@]}):"
i=1
for d in "${DEVICES[@]}"; do
name=$(adb -s "$d" shell getprop ro.product.model 2>/dev/null | tr -d '\r')
[ -z "$name" ] && name=$(adb -s "$d" shell getprop ro.product.marketname 2>/dev/null | tr -d '\r')
echo " $i) $d $name"
i=$((i+1))
done
for d in "${DEVICES[@]}"; do
echo "Installing on $d..."
adb -s "$d" install -r "$APK"
done
echo "Install OK"
@@ -1,3 +0,0 @@
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2g
Binary file not shown.
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-249
View File
@@ -1,249 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
@@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "lightsout-android"
include(":app")
-22
View File
@@ -1,22 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(lightsout VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 COMPONENTS Widgets QUIET)
if(NOT Qt6_FOUND)
find_package(Qt5 COMPONENTS Widgets REQUIRED)
set(QT_LIBS Qt5::Widgets)
else()
set(QT_LIBS Qt6::Widgets)
endif()
add_executable(lightsout
src/main.cpp
src/mainwindow.cpp
src/boardwidget.cpp
)
target_link_libraries(lightsout PRIVATE ${QT_LIBS})
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
# Сборка игры «Выключить свет» (Qt).
set -e
cd "$(dirname "$0")"
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc 2>/dev/null || echo 4)"
echo "Готово: ./build/lightsout"
-141
View File
@@ -1,141 +0,0 @@
#include "boardwidget.h"
#include <QMouseEvent>
#include <QPainter>
#include <QRandomGenerator>
namespace {
constexpr int kMargin = 8; // внешний отступ поля
constexpr int kGap = 4; // зазор между клетками
constexpr int kMinCell = 36; // минимальный размер клетки (пикс.)
constexpr int kMaxCell = 72; // максимальный размер клетки (пикс.)
}
BoardWidget::BoardWidget(QWidget *parent)
: QWidget(parent) {
setMinimumSize(220, 220);
}
// Создаёт новую партию: сбрасывает поле, затем случайными одиночными
// переключениями формирует решаемую стартовую позицию.
void BoardWidget::newGame(int size) {
m_size = size;
m_moves = 0;
m_real.fill(0, m_size * m_size);
// Позиция строится переключением случайных клеток из нулевого состояния —
// обратная последовательность ходов гарантированно решает её.
const int scrambles = m_size * m_size * 3;
for (int i = 0; i < scrambles; ++i) {
const int idx = QRandomGenerator::global()->bounded(m_size * m_size);
m_real[idx] ^= 1;
}
update();
emit stateChanged(isSolved());
}
// Включает/выключает показ подсказки.
void BoardWidget::setHintEnabled(bool enabled) {
if (m_hintEnabled == enabled) {
return;
}
m_hintEnabled = enabled;
update();
}
// Отображаемое значение клетки — XOR её самой с четырьмя соседями.
int BoardWidget::displayValue(int row, int col) const {
int v = m_real[row * m_size + col];
if (row > 0) v ^= m_real[(row - 1) * m_size + col];
if (row < m_size - 1) v ^= m_real[(row + 1) * m_size + col];
if (col > 0) v ^= m_real[row * m_size + (col - 1)];
if (col < m_size - 1) v ^= m_real[row * m_size + (col + 1)];
return v;
}
// Переключает клетку по клику и проверяет победу.
void BoardWidget::toggleCell(int row, int col) {
m_real[row * m_size + col] ^= 1;
++m_moves;
update();
emit stateChanged(isSolved());
}
// Победа — все отображаемые клетки погашены.
bool BoardWidget::isSolved() const {
for (int r = 0; r < m_size; ++r) {
for (int c = 0; c < m_size; ++c) {
if (displayValue(r, c) != 0) {
return false;
}
}
}
return true;
}
void BoardWidget::mousePressEvent(QMouseEvent *event) {
if (m_size <= 0 || event->button() != Qt::LeftButton) {
QWidget::mousePressEvent(event);
return;
}
const QRectF board = boardRect();
if (!board.contains(event->pos())) {
return;
}
const double cell = (board.width() - (m_size - 1) * kGap) / m_size;
const int col = static_cast<int>((event->pos().x() - board.left()) / (cell + kGap));
const int row = static_cast<int>((event->pos().y() - board.top()) / (cell + kGap));
if (row >= 0 && row < m_size && col >= 0 && col < m_size) {
toggleCell(row, col);
}
}
void BoardWidget::paintEvent(QPaintEvent *) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (m_size <= 0) {
return;
}
const QRectF board = boardRect();
const double cell = (board.width() - (m_size - 1) * kGap) / m_size;
const QColor onColor(255, 214, 92); // «включена» — тёплый жёлтый
const QColor offColor(44, 48, 56); // «выключена» — тёмно-серый
const QColor hintColor(120, 220, 120); // подсказка — зелёная точка
for (int r = 0; r < m_size; ++r) {
for (int c = 0; c < m_size; ++c) {
const QRectF rect(
board.left() + c * (cell + kGap),
board.top() + r * (cell + kGap),
cell, cell);
painter.setPen(QPen(QColor(20, 22, 26), 1.5));
painter.setBrush(displayValue(r, c) ? onColor : offColor);
painter.drawRoundedRect(rect, 6, 6);
// Подсказка: точка на клетках, чьё истинное значение равно 0.
if (m_hintEnabled && m_real[r * m_size + c] == 0) {
painter.setPen(Qt::NoPen);
painter.setBrush(hintColor);
const double d = cell * 0.22;
painter.drawEllipse(rect.center(), d / 2, d / 2);
}
}
}
}
// Прямоугольник, занимаемый сеткой клеток (центрированный квадрат).
QRectF BoardWidget::boardRect() const {
const int cell = kMinCell;
const double side = cell * m_size + kGap * (m_size - 1);
const double x = (width() - side) / 2.0;
const double y = (height() - side) / 2.0;
return QRectF(x, y, side, side);
}
-56
View File
@@ -1,56 +0,0 @@
/* Игровое поле Lights Out, все вызовы — GUI-поток. После создания вызвать newGame(3..8).
* Хранит real-биты и показывает XOR клетки с существующими ортогональными соседями.
* Клик меняет один real-бит и тем самым отображаемый крест; игра не блокируется после победы. */
#ifndef LIGHTSOUT_BOARDWIDGET_H
#define LIGHTSOUT_BOARDWIDGET_H
#include <QWidget>
#include <QVector>
class BoardWidget : public QWidget {
Q_OBJECT
public:
// Создаёт пустой виджет без партии; parent управляет временем жизни.
explicit BoardWidget(QWidget *parent = nullptr);
// Начинает size x size, сбрасывает ходы, сохраняет флаг подсказки и испускает stateChanged.
// size должен быть 3..8 (метод не проверяет); позиция решаемая, но может сразу оказаться погашенной.
void newGame(int size);
// Включает/выключает показ подсказки (точка на клетках real == 0).
void setHintEnabled(bool enabled);
// Показывается ли сейчас подсказка.
bool isHintEnabled() const { return m_hintEnabled; }
// Число ходов с начала партии.
int moves() const { return m_moves; }
signals:
// После newGame и каждого хода; solved проверяет отображаемые клетки, а не равенство всех real нулю.
void stateChanged(bool solved);
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
private:
// Центрированный квадрат: клетки 36 px, промежутки 4 px; не масштабируется под размер виджета.
QRectF boardRect() const;
// XOR клетки row/col и существующих соседей; индексы должны быть внутри поля.
int displayValue(int row, int col) const;
// Меняет real[row,col], увеличивает moves, обновляет рисунок и испускает stateChanged; индексы внутри поля.
void toggleCell(int row, int col);
// Проверяет, все ли отображаемые клетки погашены.
bool isSolved() const;
int m_size = 0; // 0 до newGame; затем размер поля, заданный вызывающим кодом (3..8).
QVector<quint8> m_real; // size*size битов 0/1, индекс row*size+col.
bool m_hintEnabled = false; // Зелёные точки на real == 0, сохраняется между партиями.
int m_moves = 0; // Число переключений после newGame, генерация поля не учитывается.
};
#endif // LIGHTSOUT_BOARDWIDGET_H
-21
View File
@@ -1,21 +0,0 @@
#include "mainwindow.h"
#include <QApplication>
#include <QInputDialog>
// Точка входа: запрашивает размер поля (3–8) и запускает игру.
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
bool ok = false;
const int size = QInputDialog::getInt(
nullptr, QObject::tr("Выключить свет"),
QObject::tr("Размер поля (3–8):"), 5, 3, 8, 1, &ok);
if (!ok) {
return 0;
}
MainWindow window(size);
window.show();
return app.exec();
}
-73
View File
@@ -1,73 +0,0 @@
#include "mainwindow.h"
#include "boardwidget.h"
#include <QInputDialog>
#include <QLabel>
#include <QMenuBar>
#include <QMessageBox>
#include <QPushButton>
#include <QToolBar>
#include <QVBoxLayout>
#include <QWidget>
MainWindow::MainWindow(int size, QWidget *parent)
: QMainWindow(parent), m_size(size) {
setWindowTitle(QStringLiteral("Выключить свет"));
m_board = new BoardWidget(this);
m_board->newGame(m_size);
m_movesLabel = new QLabel(this);
m_movesLabel->setText(tr("Ходы: 0"));
auto *hintButton = new QPushButton(tr("Подсказка"), this);
hintButton->setCheckable(true);
connect(hintButton, &QPushButton::toggled, this, &MainWindow::toggleHint);
auto *newButton = new QPushButton(tr("Новая игра"), this);
connect(newButton, &QPushButton::clicked, this, &MainWindow::newGame);
auto *controls = new QWidget(this);
auto *layout = new QVBoxLayout(controls);
layout->addWidget(m_board, 1);
layout->addWidget(m_movesLabel);
layout->addWidget(hintButton);
layout->addWidget(newButton);
setCentralWidget(controls);
connect(m_board, &BoardWidget::stateChanged, this, &MainWindow::onBoardStateChanged);
}
// Начинает новую партию, запрашивая размер поля у пользователя.
void MainWindow::newGame() {
bool ok = false;
const int size = QInputDialog::getInt(
this, tr("Новая игра"),
tr("Размер поля (3–8):"), m_size, 3, 8, 1, &ok);
if (!ok) {
return;
}
m_size = size;
m_board->newGame(m_size);
m_movesLabel->setText(tr("Ходы: 0"));
}
// Переключает показ подсказок.
void MainWindow::toggleHint() {
m_board->setHintEnabled(!m_board->isHintEnabled());
}
// Обновляет счётчик ходов и сообщает о победе.
void MainWindow::onBoardStateChanged(bool solved) {
m_movesLabel->setText(tr("Ходы: %1").arg(m_board->moves()));
if (solved) {
const auto ret = QMessageBox::information(
this, tr("Победа!"),
tr("Свет выключен за %1 ходов.").arg(m_board->moves()),
QMessageBox::Ok | QMessageBox::Reset, QMessageBox::Ok);
if (ret == QMessageBox::Reset) {
newGame();
}
}
}
-31
View File
@@ -1,31 +0,0 @@
/* Главное окно Lights Out: доска, новая партия, подсказка и счётчик ходов.
* Создавать/использовать в GUI-потоке; размер начальной партии задаёт вызывающий код. */
#ifndef LIGHTSOUT_MAINWINDOW_H
#define LIGHTSOUT_MAINWINDOW_H
#include <QMainWindow>
class BoardWidget;
class QLabel;
class MainWindow : public QMainWindow {
Q_OBJECT
public:
// Создаёт дочерние виджеты и начальную партию size x size; требуется size=3..8, проверки здесь нет.
explicit MainWindow(int size, QWidget *parent = nullptr);
private slots:
// Спрашивает размер 3..8; отмена оставляет текущую партию. Новая партия сохраняет настройку подсказки.
void newGame();
// Инвертирует флаг подсказки доски при переключении checkable-кнопки.
void toggleHint();
// Обновляет счётчик; при solved показывает победу, Reset открывает диалог выбора размера новой игры.
void onBoardStateChanged(bool solved);
private:
int m_size; // Размер начальной/последней принятой партии.
BoardWidget *m_board; // Доска принадлежит centralWidget окна.
QLabel *m_movesLabel; // Дочерняя метка числа ходов.
};
#endif // LIGHTSOUT_MAINWINDOW_H