Fix BLE link display and remove obsolete tool projects
This commit is contained in:
1 parent
f84622966e
commit
5152b17114
68 files changed
+300
-3885
No files matched your search
@@ -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 и уничтожает радио;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 выключен. */
|
||||
|
||||
@@ -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 не меняется. */
|
||||
|
||||
@@ -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
@@ -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--) {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, отказ сохраняет у вызывающего. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.gradle/
|
||||
.kotlin/
|
||||
**/build/
|
||||
local.properties
|
||||
@@ -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>
|
||||
-533
@@ -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"
|
||||
}
|
||||
}
|
||||
-169
@@ -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
|
||||
)
|
||||
}
|
||||
-133
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx2g
|
||||
Vendored
-4
@@ -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())
|
||||
@@ -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", "")
|
||||
))
|
||||
|
||||
+25
-17
@@ -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()) {
|
||||
|
||||
+23
-1
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Vendored
-249
@@ -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")
|
||||
@@ -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})
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user