diff --git a/doc/ble_etcp.txt b/doc/ble_etcp.txt index 025cafece..41c8d55af 100644 --- a/doc/ble_etcp.txt +++ b/doc/ble_etcp.txt @@ -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 и уничтожает радио; diff --git a/doc/packet_link.txt b/doc/packet_link.txt index 644279e27..b77101137 100644 --- a/doc/packet_link.txt +++ b/doc/packet_link.txt @@ -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. diff --git a/lib/ble/ble_transport.c b/lib/ble/ble_transport.c index dfa723ce6..7cb39f3d3 100644 --- a/lib/ble/ble_transport.c +++ b/lib/ble/ble_transport.c @@ -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; diff --git a/lib/ble/ble_transport.h b/lib/ble/ble_transport.h index 9a990f05f..9382eb172 100644 --- a/lib/ble/ble_transport.h +++ b/lib/ble/ble_transport.h @@ -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. */ diff --git a/lib/packet_link.c b/lib/packet_link.c index a6f4898d0..5deda4bae 100644 --- a/lib/packet_link.c +++ b/lib/packet_link.c @@ -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; diff --git a/lib/packet_link.h b/lib/packet_link.h index 705882c02..a1e8d6002 100644 --- a/lib/packet_link.h +++ b/lib/packet_link.h @@ -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 выключен. */ diff --git a/lib/packet_link_backend.h b/lib/packet_link_backend.h index 10ea41673..3e288c321 100644 --- a/lib/packet_link_backend.h +++ b/lib/packet_link_backend.h @@ -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 не меняется. */ diff --git a/src/chat/chat_member.h b/src/chat/chat_member.h index ea86dfeca..b8ba35cc3 100644 --- a/src/chat/chat_member.h +++ b/src/chat/chat_member.h @@ -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) diff --git a/src/chat/chat_status.c b/src/chat/chat_status.c index 70e5ee91b..9eabce2e4 100644 --- a/src/chat/chat_status.c +++ b/src/chat/chat_status.c @@ -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--) { diff --git a/src/transport_layer/etcp_connections.c b/src/transport_layer/etcp_connections.c index d14cc6fa8..e301a2964 100644 --- a/src/transport_layer/etcp_connections.c +++ b/src/transport_layer/etcp_connections.c @@ -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++; diff --git a/src/transport_layer/etcp_link_io.c b/src/transport_layer/etcp_link_io.c index 25ebb68e5..16f0e69ca 100644 --- a/src/transport_layer/etcp_link_io.c +++ b/src/transport_layer/etcp_link_io.c @@ -9,6 +9,10 @@ #include "../../lib/debug_config.h" #include #include +#include +#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; diff --git a/src/transport_layer/etcp_link_io.h b/src/transport_layer/etcp_link_io.h index aff177777..4829b01dc 100644 --- a/src/transport_layer/etcp_link_io.h +++ b/src/transport_layer/etcp_link_io.h @@ -5,6 +5,7 @@ #define ETCP_LINK_IO_H #include "../../lib/packet_link_backend.h" #include "../../lib/socket_compat.h" +#include #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, отказ сохраняет у вызывающего. */ diff --git a/tests/test_etcp_driver.c b/tests/test_etcp_driver.c index cbd8c2bd1..5ce6cf053 100644 --- a/tests/test_etcp_driver.c +++ b/tests/test_etcp_driver.c @@ -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); diff --git a/tests/test_packet_link.c b/tests/test_packet_link.c index 26b568ee1..590455f3a 100644 --- a/tests/test_packet_link.c +++ b/tests/test_packet_link.c @@ -9,6 +9,7 @@ #include "../lib/debug_config.h" #include #include +#include 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) { diff --git a/tools/ble-test-android/.gitignore b/tools/ble-test-android/.gitignore deleted file mode 100644 index 778e2bc8e..000000000 --- a/tools/ble-test-android/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.gradle/ -.kotlin/ -**/build/ -local.properties diff --git a/tools/ble-test-android/README.md b/tools/ble-test-android/README.md deleted file mode 100644 index 3b2de9c3b..000000000 --- a/tools/ble-test-android/README.md +++ /dev/null @@ -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 = 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`. diff --git a/tools/ble-test-android/app/build.gradle.kts b/tools/ble-test-android/app/build.gradle.kts deleted file mode 100644 index 367a0a2c8..000000000 --- a/tools/ble-test-android/app/build.gradle.kts +++ /dev/null @@ -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")) -} diff --git a/tools/ble-test-android/app/src/androidTest/java/com/utun/bletest/BleApiProbe.kt b/tools/ble-test-android/app/src/androidTest/java/com/utun/bletest/BleApiProbe.kt deleted file mode 100644 index 513424729..000000000 --- a/tools/ble-test-android/app/src/androidTest/java/com/utun/bletest/BleApiProbe.kt +++ /dev/null @@ -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() } -} diff --git a/tools/ble-test-android/app/src/main/AndroidManifest.xml b/tools/ble-test-android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 1eef6da1e..000000000 --- a/tools/ble-test-android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - diff --git a/tools/ble-test-android/app/src/main/java/com/utun/bletest/MainActivity.kt b/tools/ble-test-android/app/src/main/java/com/utun/bletest/MainActivity.kt deleted file mode 100644 index b9e9e984a..000000000 --- a/tools/ble-test-android/app/src/main/java/com/utun/bletest/MainActivity.kt +++ /dev/null @@ -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, 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 зарезервирован" } } - } -} diff --git a/tools/ble-test-android/app/src/main/java/com/utun/bletest/TestApplication.kt b/tools/ble-test-android/app/src/main/java/com/utun/bletest/TestApplication.kt deleted file mode 100644 index 2df75b275..000000000 --- a/tools/ble-test-android/app/src/main/java/com/utun/bletest/TestApplication.kt +++ /dev/null @@ -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 - } -} diff --git a/tools/ble-test-android/app/src/main/res/drawable/ic_ble.xml b/tools/ble-test-android/app/src/main/res/drawable/ic_ble.xml deleted file mode 100644 index 72ea35784..000000000 --- a/tools/ble-test-android/app/src/main/res/drawable/ic_ble.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/tools/ble-test-android/app/src/main/res/xml/data_extraction_rules.xml b/tools/ble-test-android/app/src/main/res/xml/data_extraction_rules.xml deleted file mode 100644 index fcd964a2a..000000000 --- a/tools/ble-test-android/app/src/main/res/xml/data_extraction_rules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/tools/ble-test-android/ble-transport/build.gradle.kts b/tools/ble-test-android/ble-transport/build.gradle.kts deleted file mode 100644 index 010f8f609..000000000 --- a/tools/ble-test-android/ble-transport/build.gradle.kts +++ /dev/null @@ -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") -} diff --git a/tools/ble-test-android/ble-transport/src/main/AndroidManifest.xml b/tools/ble-test-android/ble-transport/src/main/AndroidManifest.xml deleted file mode 100644 index 0d3af0717..000000000 --- a/tools/ble-test-android/ble-transport/src/main/AndroidManifest.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleBroadcastService.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleBroadcastService.kt deleted file mode 100644 index 5999c08a7..000000000 --- a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleBroadcastService.kt +++ /dev/null @@ -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 = 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 = 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 = connections.drainReceived(nodeId, limit) - /** Неизменяемые снимки известных соединений; sent учитывает только сообщения с удалённым ACK. */ - fun connectionSnapshots(): List = 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() - 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) { - 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" - } -} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleConnectionQueues.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleConnectionQueues.kt deleted file mode 100644 index e10c440ac..000000000 --- a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleConnectionQueues.kt +++ /dev/null @@ -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() - private val rx = ArrayDeque() - 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 { - 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) -} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleGattTransport.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleGattTransport.kt deleted file mode 100644 index 3b5bd2832..000000000 --- a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleGattTransport.kt +++ /dev/null @@ -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() - } - 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() - private val states = ConcurrentHashMap() - private val discoveries = LinkedHashMap() - private val retryAt = HashMap() - private val byNode = HashMap() - private val incoming = HashMap() - 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 { - require(limit > 0) - val result = states[nodeId]?.drainReceived(limit).orEmpty() - if (result.isNotEmpty()) worker.post { returnCredit(nodeId) } - return result - } - - fun snapshots(): List = 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() - - 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() - } -} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleProtocol.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleProtocol.kt deleted file mode 100644 index 58e1cbd3a..000000000 --- a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleProtocol.kt +++ /dev/null @@ -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)) - } -} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleQueues.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleQueues.kt deleted file mode 100644 index e496b3dd8..000000000 --- a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleQueues.kt +++ /dev/null @@ -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, 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() - private val rx = ArrayDeque() - private val peers = LinkedHashMap(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 { - 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 - ) -} diff --git a/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleConnectionQueuesTest.kt b/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleConnectionQueuesTest.kt deleted file mode 100644 index ae43604e7..000000000 --- a/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleConnectionQueuesTest.kt +++ /dev/null @@ -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()) - } -} diff --git a/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleQueuesTest.kt b/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleQueuesTest.kt deleted file mode 100644 index 826c819e3..000000000 --- a/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleQueuesTest.kt +++ /dev/null @@ -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() - 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() - 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() - 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() - 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() - 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()) - val results = Collections.synchronizedList(mutableListOf()) - 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() - while (queues.peekTx() != null) { - val seq = queues.peekTx()!!.sequence - numbers.add(seq) - queues.sent(seq) - } - assertEquals((1L..64L).toList(), numbers) - } -} diff --git a/tools/ble-test-android/build.gradle.kts b/tools/ble-test-android/build.gradle.kts deleted file mode 100644 index 0cedf8c36..000000000 --- a/tools/ble-test-android/build.gradle.kts +++ /dev/null @@ -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 -} diff --git a/tools/ble-test-android/build.sh b/tools/ble-test-android/build.sh deleted file mode 100755 index 2caf30fff..000000000 --- a/tools/ble-test-android/build.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -# По умолчанию только сборка и проверки. install — установка готового 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 diff --git a/tools/ble-test-android/gradle.properties b/tools/ble-test-android/gradle.properties deleted file mode 100644 index e0011a56c..000000000 --- a/tools/ble-test-android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -android.useAndroidX=true -kotlin.code.style=official -org.gradle.jvmargs=-Xmx2g diff --git a/tools/ble-test-android/gradlew b/tools/ble-test-android/gradlew deleted file mode 100755 index 46351e38c..000000000 --- a/tools/ble-test-android/gradlew +++ /dev/null @@ -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" "$@" diff --git a/tools/ble-test-android/settings.gradle.kts b/tools/ble-test-android/settings.gradle.kts deleted file mode 100644 index 804b3707d..000000000 --- a/tools/ble-test-android/settings.gradle.kts +++ /dev/null @@ -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") diff --git a/tools/ble-test-android/tests/run_two_devices.py b/tools/ble-test-android/tests/run_two_devices.py deleted file mode 100644 index 5bb0b524d..000000000 --- a/tools/ble-test-android/tests/run_two_devices.py +++ /dev/null @@ -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()) diff --git a/tools/cgathui_common/ble_etcp.c b/tools/cgathui_common/ble_etcp.c index 959d74023..149855113 100644 --- a/tools/cgathui_common/ble_etcp.c +++ b/tools/cgathui_common/ble_etcp.c @@ -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) { diff --git a/tools/cgathui_common/tests/test_ble_etcp.c b/tools/cgathui_common/tests/test_ble_etcp.c index 93404ae57..1bdcd68f2 100644 --- a/tools/cgathui_common/tests/test_ble_etcp.c +++ b/tools/cgathui_common/tests/test_ble_etcp.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(); diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 522772500..901009699 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -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", "") )) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 63b4aeffd..df4d77131 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -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()) { diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChatRepositorySnapshotTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChatRepositorySnapshotTest.kt index 7264a2ba6..3ac224e14 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChatRepositorySnapshotTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChatRepositorySnapshotTest.kt @@ -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) + } + } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 2a0214cd2..cf84f358b 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -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); diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index 025d82d8f..a22fb9960 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -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 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("● %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("● %2").arg(c, description)); if (lk.sockName[0]) lines.append(QString(" %1").arg(QString::fromUtf8(lk.sockName).toHtmlEscaped())); } diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h index 1ed9a7bd0..a96f2c393 100644 --- a/tools/chatgui/src/accountlist.h +++ b/tools/chatgui/src/accountlist.h @@ -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 diff --git a/tools/chatgui/tests/test_member_detail.cpp b/tools/chatgui/tests/test_member_detail.cpp index 6352f539a..aa82beaba 100644 --- a/tools/chatgui/tests/test_member_detail.cpp +++ b/tools/chatgui/tests/test_member_detail.cpp @@ -253,14 +253,34 @@ private slots: auto* data = reinterpret_cast(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(link + 43), ""); + 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(link + 42), ""); + strcpy(reinterpret_cast(link + 62), "TCP+REALITY"); deliver(bytes); QVERIFY(body(panel).contains("[::1]:1333")); QVERIFY(body(panel).contains("[::2]:443")); QVERIFY(body(panel).contains("")); } + 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(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(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(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); diff --git a/tools/lightsout-android/.gitignore b/tools/lightsout-android/.gitignore deleted file mode 100644 index 9a0fe6e91..000000000 --- a/tools/lightsout-android/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -build/ -.gradle/ -*.apk -*.iml -.idea/ -local.properties -.cxx/ diff --git a/tools/lightsout-android/app/build.gradle.kts b/tools/lightsout-android/app/build.gradle.kts deleted file mode 100644 index be69304d9..000000000 --- a/tools/lightsout-android/app/build.gradle.kts +++ /dev/null @@ -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") -} diff --git a/tools/lightsout-android/app/src/main/AndroidManifest.xml b/tools/lightsout-android/app/src/main/AndroidManifest.xml deleted file mode 100644 index ac0c0773c..000000000 --- a/tools/lightsout-android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - diff --git a/tools/lightsout-android/app/src/main/java/com/utun/lightsout/LightsOutGame.kt b/tools/lightsout-android/app/src/main/java/com/utun/lightsout/LightsOutGame.kt deleted file mode 100644 index dbdc7df9a..000000000 --- a/tools/lightsout-android/app/src/main/java/com/utun/lightsout/LightsOutGame.kt +++ /dev/null @@ -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() - - 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()) - } -} diff --git a/tools/lightsout-android/app/src/main/java/com/utun/lightsout/MainActivity.kt b/tools/lightsout-android/app/src/main/java/com/utun/lightsout/MainActivity.kt deleted file mode 100644 index 311e66083..000000000 --- a/tools/lightsout-android/app/src/main/java/com/utun/lightsout/MainActivity.kt +++ /dev/null @@ -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(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) - ) - } - } - } - } - } - } -} diff --git a/tools/lightsout-android/app/src/main/res/values/strings.xml b/tools/lightsout-android/app/src/main/res/values/strings.xml deleted file mode 100644 index 21ce5f48d..000000000 --- a/tools/lightsout-android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Выключить свет - diff --git a/tools/lightsout-android/app/src/main/res/values/themes.xml b/tools/lightsout-android/app/src/main/res/values/themes.xml deleted file mode 100644 index e3cd9bd59..000000000 --- a/tools/lightsout-android/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,4 +0,0 @@ - - -