feat: 实现 Python 与 Java SDK 连接收发及其余接口
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
@@ -0,0 +1,10 @@
|
||||
Copyright (c) 2026 Nixevol. All rights reserved.
|
||||
|
||||
本仓库的源代码、文档、各语言 SDK 和构建产物(包括发布的软件包和 Docker 镜像)均为专有软件。
|
||||
源代码和发布物公开可读,不代表授予任何使用许可。未经版权所有者书面许可,不得使用、复制、
|
||||
修改、合并、发布、分发、再许可或出售其任何部分。
|
||||
|
||||
This repository, including its source code, documentation, SDKs and build artifacts (including
|
||||
published packages and Docker images), is proprietary software. Public visibility does not grant
|
||||
any license. No part of it may be used, copied, modified, merged, published, distributed,
|
||||
sublicensed or sold without prior written permission from the copyright holder.
|
||||
@@ -0,0 +1,34 @@
|
||||
# NixMsg Java / Android SDK
|
||||
|
||||
坐标:`asia.asio.nixmsg:nixmsg-sdk`
|
||||
包名:`asia.asio.nixmsg`
|
||||
字节码目标:Java 8
|
||||
接口:`CompletableFuture`
|
||||
|
||||
## Android
|
||||
|
||||
最低 API **24**。MQTT 长连接由应用自行放入**前台服务**,SDK 不创建也不托管服务生命周期。
|
||||
|
||||
## 依赖
|
||||
|
||||
Maven / Gradle 仓库:
|
||||
|
||||
```
|
||||
https://git.asio.asia/api/packages/nixevol/maven
|
||||
```
|
||||
|
||||
HiveMQ MQTT Client(含 WebSocket:`webSocketConfig` + `netty-codec-http`)。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```java
|
||||
Client c = new Client();
|
||||
c.onSession(token -> { /* 应用保存 */ });
|
||||
c.onMessage(msg -> System.out.println(msg.id + " " + msg.body.data));
|
||||
c.connect("ws://127.0.0.1:7443/mqtt", "device-1", "secret", null)
|
||||
.thenCompose(v -> c.send(new Types.Target("endpoint", "device-2"), new Types.Body("hello"), new Types.SendOptions()))
|
||||
.join();
|
||||
c.close();
|
||||
```
|
||||
|
||||
许可证见 `LICENSE`(专有)。
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>asia.asio.nixmsg</groupId>
|
||||
<artifactId>nixmsg-sdk</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>nixmsg-sdk</name>
|
||||
<description>NixMsg Java/Android SDK</description>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Proprietary</name>
|
||||
<url>https://git.asio.asia/nixevol/NixMsg/src/branch/main/LICENSE</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.release>8</maven.compiler.release>
|
||||
<hivemq.mqtt.version>1.3.5</hivemq.mqtt.version>
|
||||
<junit.version>4.13.2</junit.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.hivemq</groupId>
|
||||
<artifactId>hivemq-mqtt-client</artifactId>
|
||||
<version>${hivemq.mqtt.version}</version>
|
||||
</dependency>
|
||||
<!-- WebSocket 传输所需 Netty HTTP 编解码(HiveMQ 文档中的 websocket 能力) -->
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-codec-http</artifactId>
|
||||
<version>4.1.118.Final</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.11.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>8</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.2</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<distributionManagement>
|
||||
<repository>
|
||||
<id>asio-gitea</id>
|
||||
<url>https://git.asio.asia/api/packages/nixevol/maven</url>
|
||||
</repository>
|
||||
</distributionManagement>
|
||||
</project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
package asia.asio.nixmsg;
|
||||
|
||||
/** SDK 错误,code 与协议错误码或连接原因对齐。 */
|
||||
public class NixMsgException extends RuntimeException {
|
||||
private final String code;
|
||||
|
||||
public NixMsgException(String code, String message) {
|
||||
super(message == null || message.isEmpty() ? code : message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package asia.asio.nixmsg;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.ToNumberPolicy;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
final class Protocol {
|
||||
static final Gson GSON = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.create();
|
||||
|
||||
private Protocol() {}
|
||||
|
||||
static byte[] dumps(Object obj) {
|
||||
return GSON.toJson(obj).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> loads(byte[] data) {
|
||||
return GSON.fromJson(new String(data, StandardCharsets.UTF_8), Map.class);
|
||||
}
|
||||
|
||||
static String upTopic(String endpointId) {
|
||||
return "nix/c/" + endpointId + "/up";
|
||||
}
|
||||
|
||||
static String downTopic(String endpointId) {
|
||||
return "nix/c/" + endpointId + "/down";
|
||||
}
|
||||
|
||||
static String normalizeMqttWsUrl(String url) {
|
||||
String raw = url.trim();
|
||||
if (!raw.contains("://")) {
|
||||
raw = "ws://" + raw;
|
||||
}
|
||||
URI u = URI.create(raw);
|
||||
String path = u.getPath() == null ? "" : u.getPath();
|
||||
if (!path.endsWith("/mqtt")) {
|
||||
if (path.endsWith("/")) {
|
||||
path = path + "mqtt";
|
||||
} else if (path.isEmpty()) {
|
||||
path = "/mqtt";
|
||||
} else {
|
||||
path = path + "/mqtt";
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new URI(u.getScheme(), u.getUserInfo(), u.getHost(), u.getPort(), path, u.getQuery(), u.getFragment()).toString();
|
||||
} catch (Exception e) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从连接地址推出注册 HTTP 地址。 */
|
||||
static String registerUrlFromConnect(String connectUrl) {
|
||||
String raw = connectUrl.trim();
|
||||
if (!raw.contains("://")) {
|
||||
raw = "ws://" + raw;
|
||||
}
|
||||
URI u = URI.create(raw);
|
||||
String scheme = u.getScheme() == null ? "ws" : u.getScheme().toLowerCase();
|
||||
String httpScheme;
|
||||
if ("wss".equals(scheme) || "mqtts".equals(scheme) || "https".equals(scheme)) {
|
||||
httpScheme = "https";
|
||||
} else {
|
||||
httpScheme = "http";
|
||||
}
|
||||
String path = u.getPath() == null ? "" : u.getPath();
|
||||
if (path.endsWith("/mqtt")) {
|
||||
path = path.substring(0, path.length() - "/mqtt".length());
|
||||
}
|
||||
if (path.endsWith("/")) {
|
||||
path = path.substring(0, path.length() - 1);
|
||||
}
|
||||
path = path + "/api/client/register";
|
||||
try {
|
||||
return new URI(httpScheme, u.getUserInfo(), u.getHost(), u.getPort(), path, null, null).toString();
|
||||
} catch (Exception e) {
|
||||
throw new NixMsgException("bad_request", "无效连接地址");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package asia.asio.nixmsg;
|
||||
|
||||
import com.hivemq.client.mqtt.MqttClient;
|
||||
import com.hivemq.client.mqtt.MqttGlobalPublishFilter;
|
||||
import com.hivemq.client.mqtt.datatypes.MqttQos;
|
||||
import com.hivemq.client.mqtt.lifecycle.MqttDisconnectSource;
|
||||
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient;
|
||||
import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientBuilder;
|
||||
import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAck;
|
||||
import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAckReasonCode;
|
||||
import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5Disconnect;
|
||||
import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5DisconnectReasonCode;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** MQTT 传输抽象。 */
|
||||
public interface Transport {
|
||||
void setHandlers(Runnable onConnected, BiConsumer<String, Boolean> onDisconnected, Consumer<byte[]> onDown);
|
||||
|
||||
void connect(ConnectParams params);
|
||||
|
||||
void subscribe(String topic);
|
||||
|
||||
void publish(String topic, byte[] payload);
|
||||
|
||||
void disconnect();
|
||||
|
||||
final class ConnectParams {
|
||||
public final String url;
|
||||
public final String clientId;
|
||||
public final String username;
|
||||
public final String password;
|
||||
public final boolean cleanStart;
|
||||
public final int sessionExpiry;
|
||||
public final boolean useTcp;
|
||||
public final long timeoutMs;
|
||||
|
||||
public ConnectParams(String url, String clientId, String username, String password,
|
||||
boolean cleanStart, int sessionExpiry, boolean useTcp, long timeoutMs) {
|
||||
this.url = url;
|
||||
this.clientId = clientId;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.cleanStart = cleanStart;
|
||||
this.sessionExpiry = sessionExpiry;
|
||||
this.useTcp = useTcp;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 单元测试假传输。 */
|
||||
final class FakeTransport implements Transport {
|
||||
final List<Transport.ConnectParams> connects = new ArrayList<Transport.ConnectParams>();
|
||||
final List<byte[]> publishes = new CopyOnWriteArrayList<byte[]>();
|
||||
final List<String> subscriptions = new ArrayList<String>();
|
||||
volatile String nextConnackFail;
|
||||
Map<String, Object> autoHello;
|
||||
boolean autoSendOk = true;
|
||||
final List<Function<Map<String, Object>, Map<String, Object>>> upHandlers =
|
||||
new CopyOnWriteArrayList<Function<Map<String, Object>, Map<String, Object>>>();
|
||||
|
||||
private Runnable onConnected;
|
||||
private BiConsumer<String, Boolean> onDisconnected;
|
||||
private Consumer<byte[]> onDown;
|
||||
private volatile boolean connected;
|
||||
|
||||
FakeTransport() {
|
||||
autoHello = new LinkedHashMap<String, Object>();
|
||||
autoHello.put("server_time_ms", 1750000000000L);
|
||||
autoHello.put("server_version", "0.1.0");
|
||||
autoHello.put("max_body_bytes", 262144);
|
||||
autoHello.put("max_meta_bytes", 4096);
|
||||
autoHello.put("max_frame_bytes", 786432);
|
||||
autoHello.put("max_ttl_seconds", 2592000);
|
||||
autoHello.put("max_schedule_seconds", 31536000);
|
||||
autoHello.put("ack_timeout_seconds", 300);
|
||||
autoHello.put("session_token", "nst_test_token");
|
||||
}
|
||||
|
||||
void onUp(Function<Map<String, Object>, Map<String, Object>> h) {
|
||||
upHandlers.add(h);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setHandlers(Runnable onConnected, BiConsumer<String, Boolean> onDisconnected, Consumer<byte[]> onDown) {
|
||||
this.onConnected = onConnected;
|
||||
this.onDisconnected = onDisconnected;
|
||||
this.onDown = onDown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(ConnectParams params) {
|
||||
connects.add(params);
|
||||
if (nextConnackFail != null) {
|
||||
String fail = nextConnackFail;
|
||||
nextConnackFail = null;
|
||||
boolean stop = "session_invalid".equals(fail) || "bad_credentials".equals(fail) || "banned".equals(fail);
|
||||
if (onDisconnected != null) {
|
||||
onDisconnected.accept(fail, stop);
|
||||
}
|
||||
return;
|
||||
}
|
||||
connected = true;
|
||||
if (onConnected != null) {
|
||||
onConnected.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void subscribe(String topic) {
|
||||
subscriptions.add(topic);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(String topic, byte[] payload) {
|
||||
publishes.add(payload);
|
||||
Map<String, Object> frame = Protocol.loads(payload);
|
||||
for (Function<Map<String, Object>, Map<String, Object>> h : upHandlers) {
|
||||
Map<String, Object> resp = h.apply(frame);
|
||||
if (resp != null) {
|
||||
injectDown(Protocol.dumps(resp));
|
||||
return;
|
||||
}
|
||||
}
|
||||
String type = str(frame.get("type"));
|
||||
Object rid = frame.get("rid");
|
||||
if ("hello".equals(type) && autoHello != null) {
|
||||
Map<String, Object> resp = new LinkedHashMap<String, Object>();
|
||||
resp.put("v", 1);
|
||||
resp.put("type", "resp");
|
||||
resp.put("rid", rid);
|
||||
resp.put("ok", true);
|
||||
resp.put("data", autoHello);
|
||||
injectDown(Protocol.dumps(resp));
|
||||
return;
|
||||
}
|
||||
if ("send".equals(type) && autoSendOk) {
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("id", frame.get("id"));
|
||||
Object sat = frame.get("send_at_ms");
|
||||
data.put("send_at_ms", sat == null ? 0 : sat);
|
||||
data.put("state", "dispatched");
|
||||
Map<String, Object> resp = new LinkedHashMap<String, Object>();
|
||||
resp.put("v", 1);
|
||||
resp.put("type", "resp");
|
||||
resp.put("rid", rid);
|
||||
resp.put("ok", true);
|
||||
resp.put("data", data);
|
||||
injectDown(Protocol.dumps(resp));
|
||||
return;
|
||||
}
|
||||
if ("ack".equals(type)) {
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("result", "accepted");
|
||||
Map<String, Object> resp = new LinkedHashMap<String, Object>();
|
||||
resp.put("v", 1);
|
||||
resp.put("type", "resp");
|
||||
resp.put("rid", rid);
|
||||
resp.put("ok", true);
|
||||
resp.put("data", data);
|
||||
injectDown(Protocol.dumps(resp));
|
||||
return;
|
||||
}
|
||||
if (type != null && !"hello".equals(type) && !"send".equals(type) && rid != null) {
|
||||
Map<String, Object> resp = new LinkedHashMap<String, Object>();
|
||||
resp.put("v", 1);
|
||||
resp.put("type", "resp");
|
||||
resp.put("rid", rid);
|
||||
resp.put("ok", true);
|
||||
resp.put("data", new LinkedHashMap<String, Object>());
|
||||
injectDown(Protocol.dumps(resp));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
boolean was = connected;
|
||||
connected = false;
|
||||
if (was && onDisconnected != null) {
|
||||
onDisconnected.accept(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
void injectDown(byte[] payload) {
|
||||
if (onDown != null) {
|
||||
onDown.accept(payload);
|
||||
}
|
||||
}
|
||||
|
||||
void simulateTakenOver() {
|
||||
connected = false;
|
||||
if (onDisconnected != null) {
|
||||
onDisconnected.accept("taken_over", true);
|
||||
}
|
||||
}
|
||||
|
||||
void simulateNetworkDrop() {
|
||||
connected = false;
|
||||
if (onDisconnected != null) {
|
||||
onDisconnected.accept("network", false);
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
|
||||
/** HiveMQ MQTT 5 + WebSocket(子协议 mqtt)。 */
|
||||
final class HiveMqTransport implements Transport {
|
||||
private Runnable onConnected;
|
||||
private BiConsumer<String, Boolean> onDisconnected;
|
||||
private Consumer<byte[]> onDown;
|
||||
private volatile Mqtt5AsyncClient client;
|
||||
|
||||
@Override
|
||||
public void setHandlers(Runnable onConnected, BiConsumer<String, Boolean> onDisconnected, Consumer<byte[]> onDown) {
|
||||
this.onConnected = onConnected;
|
||||
this.onDisconnected = onDisconnected;
|
||||
this.onDown = onDown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(final ConnectParams params) {
|
||||
disconnectQuiet();
|
||||
URI u = URI.create(params.url.contains("://") ? params.url : "ws://" + params.url);
|
||||
boolean useTcp = params.useTcp
|
||||
|| "mqtt".equalsIgnoreCase(u.getScheme())
|
||||
|| "mqtts".equalsIgnoreCase(u.getScheme());
|
||||
String host = u.getHost() == null ? "localhost" : u.getHost();
|
||||
int port = u.getPort();
|
||||
if (port < 0) {
|
||||
if (useTcp) {
|
||||
port = "mqtts".equalsIgnoreCase(u.getScheme()) ? 8883 : 1883;
|
||||
} else {
|
||||
port = "wss".equalsIgnoreCase(u.getScheme()) ? 443 : 80;
|
||||
}
|
||||
}
|
||||
|
||||
Mqtt5ClientBuilder b5 = MqttClient.builder()
|
||||
.useMqttVersion5()
|
||||
.identifier(params.clientId)
|
||||
.serverHost(host)
|
||||
.serverPort(port)
|
||||
.addDisconnectedListener(context -> {
|
||||
String reason = "network";
|
||||
boolean stop = false;
|
||||
if (context.getSource() == MqttDisconnectSource.SERVER) {
|
||||
try {
|
||||
java.lang.reflect.Method m = context.getClass().getMethod("getMqttDisconnect");
|
||||
Object disc = m.invoke(context);
|
||||
if (disc instanceof Mqtt5Disconnect) {
|
||||
Mqtt5DisconnectReasonCode rc = ((Mqtt5Disconnect) disc).getReasonCode();
|
||||
if (rc == Mqtt5DisconnectReasonCode.SESSION_TAKEN_OVER) {
|
||||
reason = "taken_over";
|
||||
stop = true;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
Throwable cause = context.getCause();
|
||||
if (cause != null && cause.getMessage() != null
|
||||
&& cause.getMessage().toLowerCase().contains("taken over")) {
|
||||
reason = "taken_over";
|
||||
stop = true;
|
||||
}
|
||||
BiConsumer<String, Boolean> h = onDisconnected;
|
||||
if (h != null) {
|
||||
h.accept(reason, stop);
|
||||
}
|
||||
});
|
||||
|
||||
if (!useTcp) {
|
||||
String path = u.getPath() == null || u.getPath().isEmpty() ? "/mqtt" : u.getPath();
|
||||
if (!path.endsWith("/mqtt")) {
|
||||
path = path.endsWith("/") ? path + "mqtt" : path + "/mqtt";
|
||||
}
|
||||
String serverPath = path.startsWith("/") ? path.substring(1) : path;
|
||||
b5 = b5.webSocketConfig()
|
||||
.serverPath(serverPath)
|
||||
.subprotocol("mqtt")
|
||||
.applyWebSocketConfig();
|
||||
if ("wss".equalsIgnoreCase(u.getScheme())) {
|
||||
b5 = b5.sslWithDefaultConfig();
|
||||
}
|
||||
} else if ("mqtts".equalsIgnoreCase(u.getScheme())) {
|
||||
b5 = b5.sslWithDefaultConfig();
|
||||
}
|
||||
|
||||
Mqtt5AsyncClient c = b5.buildAsync();
|
||||
this.client = c;
|
||||
c.publishes(MqttGlobalPublishFilter.ALL, publish -> {
|
||||
Consumer<byte[]> h = onDown;
|
||||
if (h != null) {
|
||||
h.accept(publish.getPayloadAsBytes());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
Mqtt5ConnAck ack = c.connectWith()
|
||||
.cleanStart(params.cleanStart)
|
||||
.sessionExpiryInterval(params.sessionExpiry)
|
||||
.simpleAuth()
|
||||
.username(params.username)
|
||||
.password(params.password.getBytes(StandardCharsets.UTF_8))
|
||||
.applySimpleAuth()
|
||||
.send()
|
||||
.get(params.timeoutMs, TimeUnit.MILLISECONDS);
|
||||
if (ack.getReasonCode() == Mqtt5ConnAckReasonCode.SUCCESS) {
|
||||
Runnable h = onConnected;
|
||||
if (h != null) {
|
||||
h.run();
|
||||
}
|
||||
} else {
|
||||
String reason = classify(ack.getReasonCode());
|
||||
boolean stop = isStop(ack.getReasonCode());
|
||||
BiConsumer<String, Boolean> h = onDisconnected;
|
||||
if (h != null) {
|
||||
h.accept(reason, stop);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
BiConsumer<String, Boolean> h = onDisconnected;
|
||||
if (h != null) {
|
||||
h.accept("network", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void subscribe(String topic) {
|
||||
Mqtt5AsyncClient c = client;
|
||||
if (c == null) {
|
||||
throw new NixMsgException("not_connected", "未连接");
|
||||
}
|
||||
try {
|
||||
c.subscribeWith().topicFilter(topic).qos(MqttQos.AT_LEAST_ONCE).send().get(10, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new NixMsgException("busy", "订阅失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(String topic, byte[] payload) {
|
||||
Mqtt5AsyncClient c = client;
|
||||
if (c == null) {
|
||||
throw new NixMsgException("not_connected", "未连接");
|
||||
}
|
||||
try {
|
||||
c.publishWith()
|
||||
.topic(topic)
|
||||
.qos(MqttQos.AT_LEAST_ONCE)
|
||||
.payload(payload)
|
||||
.send()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new NixMsgException("busy", "发布失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
disconnectQuiet();
|
||||
}
|
||||
|
||||
private void disconnectQuiet() {
|
||||
Mqtt5AsyncClient c = client;
|
||||
client = null;
|
||||
if (c != null) {
|
||||
try {
|
||||
c.disconnect().get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isStop(Mqtt5ConnAckReasonCode code) {
|
||||
return code == Mqtt5ConnAckReasonCode.BAD_USER_NAME_OR_PASSWORD
|
||||
|| code == Mqtt5ConnAckReasonCode.NOT_AUTHORIZED
|
||||
|| code == Mqtt5ConnAckReasonCode.BANNED;
|
||||
}
|
||||
|
||||
private static String classify(Mqtt5ConnAckReasonCode code) {
|
||||
if (code == Mqtt5ConnAckReasonCode.BAD_USER_NAME_OR_PASSWORD
|
||||
|| code == Mqtt5ConnAckReasonCode.NOT_AUTHORIZED
|
||||
|| code == Mqtt5ConnAckReasonCode.BANNED) {
|
||||
return "bad_credentials";
|
||||
}
|
||||
if (code == Mqtt5ConnAckReasonCode.SERVER_UNAVAILABLE || code == Mqtt5ConnAckReasonCode.SERVER_BUSY) {
|
||||
return "busy";
|
||||
}
|
||||
return "network";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package asia.asio.nixmsg;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class Types {
|
||||
private Types() {}
|
||||
|
||||
public static final String CLIENT_NAME = "java-sdk/0.1";
|
||||
public static final int DEFAULT_MAX_BODY = 262144;
|
||||
public static final int DEFAULT_MAX_META = 4096;
|
||||
public static final int DEFAULT_MAX_FRAME = 786432;
|
||||
public static final int MIN_MAX_RECEIVE = 1024;
|
||||
public static final int SEND_QUEUE_LIMIT = 1000;
|
||||
public static final int INFLIGHT_LIMIT = 100;
|
||||
public static final int DEDUP_CAPACITY = 10000;
|
||||
public static final long CONNECT_TIMEOUT_MS = 30_000L;
|
||||
public static final long BACKOFF_INITIAL_MS = 1_000L;
|
||||
public static final long BACKOFF_MAX_MS = 30_000L;
|
||||
public static final double BACKOFF_JITTER = 0.3;
|
||||
public static final long STABLE_RESET_MS = 60_000L;
|
||||
|
||||
public enum ConnectionState {
|
||||
CONNECTING, ONLINE, RECONNECTING, OFFLINE, KICKED, AUTH_FAILED
|
||||
}
|
||||
|
||||
public static final class Target {
|
||||
public final String kind;
|
||||
public final String id;
|
||||
|
||||
public Target(String kind, String id) {
|
||||
this.kind = Objects.requireNonNull(kind);
|
||||
this.id = Objects.requireNonNull(id);
|
||||
}
|
||||
|
||||
public Map<String, Object> toMap() {
|
||||
Map<String, Object> m = new LinkedHashMap<String, Object>();
|
||||
m.put("kind", kind);
|
||||
m.put("id", id);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Body {
|
||||
public final String enc;
|
||||
public final String data;
|
||||
public String contentType;
|
||||
|
||||
public Body(String data) {
|
||||
this("utf8", data, null);
|
||||
}
|
||||
|
||||
public Body(String enc, String data, String contentType) {
|
||||
this.enc = enc;
|
||||
this.data = data;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public static Body ofBytes(byte[] raw) {
|
||||
return new Body("base64", Base64.getEncoder().encodeToString(raw), "application/octet-stream");
|
||||
}
|
||||
|
||||
public int decodedSize() {
|
||||
if ("base64".equals(enc)) {
|
||||
return Base64.getDecoder().decode(data).length;
|
||||
}
|
||||
return data.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
public String effectiveContentType() {
|
||||
if (contentType != null && !contentType.isEmpty()) {
|
||||
return contentType;
|
||||
}
|
||||
return "base64".equals(enc) ? "application/octet-stream" : "text/plain; charset=utf-8";
|
||||
}
|
||||
|
||||
public Map<String, Object> toMap() {
|
||||
Map<String, Object> m = new LinkedHashMap<String, Object>();
|
||||
m.put("enc", enc);
|
||||
m.put("data", data);
|
||||
m.put("content_type", effectiveContentType());
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SendOptions {
|
||||
public Long sendAtMs;
|
||||
public Long delayMs;
|
||||
public boolean keep;
|
||||
public Long ttlSeconds;
|
||||
public boolean receipt = true;
|
||||
public String talkPassword = "";
|
||||
public String contentType;
|
||||
public Map<String, Object> meta;
|
||||
public String messageId;
|
||||
}
|
||||
|
||||
public static final class SendResult {
|
||||
public final String id;
|
||||
public final long sendAtMs;
|
||||
public final String state;
|
||||
|
||||
public SendResult(String id, long sendAtMs, String state) {
|
||||
this.id = id;
|
||||
this.sendAtMs = sendAtMs;
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RecallResult {
|
||||
public final String result;
|
||||
public final int recalled;
|
||||
public final int accepted;
|
||||
public final int other;
|
||||
|
||||
public RecallResult(String result, int recalled, int accepted, int other) {
|
||||
this.result = result;
|
||||
this.recalled = recalled;
|
||||
this.accepted = accepted;
|
||||
this.other = other;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class IncomingMessage {
|
||||
public final String id;
|
||||
public final String from;
|
||||
public final Target to;
|
||||
public final Body body;
|
||||
public final long sendAtMs;
|
||||
public final Map<String, Object> meta;
|
||||
|
||||
public IncomingMessage(String id, String from, Target to, Body body, long sendAtMs, Map<String, Object> meta) {
|
||||
this.id = id;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.body = body;
|
||||
this.sendAtMs = sendAtMs;
|
||||
this.meta = meta == null ? Collections.<String, Object>emptyMap() : meta;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Receipt {
|
||||
public final String receiptId;
|
||||
public final String id;
|
||||
public final String endpointId;
|
||||
public final String state;
|
||||
public final String reason;
|
||||
public final long atMs;
|
||||
|
||||
public Receipt(String receiptId, String id, String endpointId, String state, String reason, long atMs) {
|
||||
this.receiptId = receiptId;
|
||||
this.id = id;
|
||||
this.endpointId = endpointId;
|
||||
this.state = state;
|
||||
this.reason = reason;
|
||||
this.atMs = atMs;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RevokedEvent {
|
||||
public final String id;
|
||||
public final String from;
|
||||
public final String reason;
|
||||
|
||||
public RevokedEvent(String id, String from, String reason) {
|
||||
this.id = id;
|
||||
this.from = from;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class PresenceEvent {
|
||||
public final String id;
|
||||
public final boolean online;
|
||||
public final long atMs;
|
||||
|
||||
public PresenceEvent(String id, boolean online, long atMs) {
|
||||
this.id = id;
|
||||
this.online = online;
|
||||
this.atMs = atMs;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GroupEvent {
|
||||
public final String groupId;
|
||||
public final String event;
|
||||
public final String endpointId;
|
||||
public final long atMs;
|
||||
|
||||
public GroupEvent(String groupId, String event, String endpointId, long atMs) {
|
||||
this.groupId = groupId;
|
||||
this.event = event;
|
||||
this.endpointId = endpointId;
|
||||
this.atMs = atMs;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HelloLimits {
|
||||
public long serverTimeMs;
|
||||
public String serverVersion = "";
|
||||
public int maxBodyBytes = DEFAULT_MAX_BODY;
|
||||
public int maxMetaBytes = DEFAULT_MAX_META;
|
||||
public int maxFrameBytes = DEFAULT_MAX_FRAME;
|
||||
public long maxTtlSeconds = 2592000;
|
||||
public long maxScheduleSeconds = 31536000;
|
||||
public long ackTimeoutSeconds = 300;
|
||||
public String sessionToken = "";
|
||||
}
|
||||
|
||||
public static final class RegisterOptions {
|
||||
public String id = "";
|
||||
public String loginPassword = "";
|
||||
public String name = "";
|
||||
public String talkPassword = "";
|
||||
}
|
||||
|
||||
public static final class RegisterResult {
|
||||
public final String id;
|
||||
public final String loginPassword;
|
||||
|
||||
public RegisterResult(String id, String loginPassword) {
|
||||
this.id = id;
|
||||
this.loginPassword = loginPassword;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ConnectionEvent {
|
||||
public final ConnectionState state;
|
||||
public final String reason;
|
||||
|
||||
public ConnectionEvent(ConnectionState state, String reason) {
|
||||
this.state = state;
|
||||
this.reason = reason == null ? "" : reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package asia.asio.nixmsg;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.UUID;
|
||||
|
||||
final class Uuid7 {
|
||||
private static final SecureRandom RAND = new SecureRandom();
|
||||
|
||||
private Uuid7() {}
|
||||
|
||||
static String next() {
|
||||
long ts = System.currentTimeMillis() & ((1L << 48) - 1);
|
||||
long randA = RAND.nextInt() & 0x0FFF;
|
||||
long randB = RAND.nextLong() & ((1L << 62) - 1);
|
||||
long msb = (ts << 16) | (0x7L << 12) | randA;
|
||||
long lsb = (0b10L << 62) | randB;
|
||||
return new UUID(msb, lsb).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package asia.asio.nixmsg;
|
||||
|
||||
import asia.asio.nixmsg.Types.Body;
|
||||
import asia.asio.nixmsg.Types.ConnectionState;
|
||||
import asia.asio.nixmsg.Types.IncomingMessage;
|
||||
import asia.asio.nixmsg.Types.SendOptions;
|
||||
import asia.asio.nixmsg.Types.SendResult;
|
||||
import asia.asio.nixmsg.Types.Target;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class ClientTest {
|
||||
private Client client;
|
||||
private FakeTransport transport;
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void connectOnline() {
|
||||
transport = new FakeTransport();
|
||||
client = new Client(transport);
|
||||
client.connectSync("ws://example.test/mqtt", "ep1", "secret", null, false);
|
||||
assertEquals(ConnectionState.ONLINE, client.getState());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanStartAndSessionExpiryEveryConnect() throws Exception {
|
||||
connectOnline();
|
||||
assertTrue(transport.connects.size() >= 1);
|
||||
Transport.ConnectParams p = transport.connects.get(0);
|
||||
assertTrue(p.cleanStart);
|
||||
assertEquals(0, p.sessionExpiry);
|
||||
assertTrue(transport.subscriptions.contains("nix/c/ep1/down"));
|
||||
|
||||
transport.simulateNetworkDrop();
|
||||
long deadline = System.currentTimeMillis() + 3000;
|
||||
while (transport.connects.size() < 2 && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertTrue(transport.connects.size() >= 2);
|
||||
for (Transport.ConnectParams c : transport.connects) {
|
||||
assertTrue(c.cleanStart);
|
||||
assertEquals(0, c.sessionExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sessionTokenCallback() {
|
||||
transport = new FakeTransport();
|
||||
client = new Client(transport);
|
||||
final List<String> tokens = new ArrayList<String>();
|
||||
client.onSession(new java.util.function.Consumer<String>() {
|
||||
@Override
|
||||
public void accept(String t) {
|
||||
tokens.add(t);
|
||||
}
|
||||
});
|
||||
client.connectSync("ws://example.test/mqtt", "ep1", "pw", null, false);
|
||||
assertEquals(1, tokens.size());
|
||||
assertEquals("nst_test_token", tokens.get(0));
|
||||
assertEquals("nst_test_token", client.getSessionToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dedupAckedRearrivalAcksAgain() throws Exception {
|
||||
connectOnline();
|
||||
final List<String> delivered = new ArrayList<String>();
|
||||
client.onMessage(new java.util.function.Consumer<IncomingMessage>() {
|
||||
@Override
|
||||
public void accept(IncomingMessage m) {
|
||||
delivered.add(m.id);
|
||||
}
|
||||
});
|
||||
Map<String, Object> msg = new LinkedHashMap<String, Object>();
|
||||
msg.put("v", 1);
|
||||
msg.put("type", "msg");
|
||||
msg.put("id", "m1");
|
||||
msg.put("from", "peer");
|
||||
Map<String, Object> to = new LinkedHashMap<String, Object>();
|
||||
to.put("kind", "endpoint");
|
||||
to.put("id", "ep1");
|
||||
msg.put("to", to);
|
||||
Map<String, Object> body = new LinkedHashMap<String, Object>();
|
||||
body.put("enc", "utf8");
|
||||
body.put("data", "hi");
|
||||
msg.put("body", body);
|
||||
msg.put("send_at_ms", 1);
|
||||
transport.injectDown(Protocol.dumps(msg));
|
||||
Thread.sleep(100);
|
||||
assertEquals(1, delivered.size());
|
||||
int before = transport.publishes.size();
|
||||
transport.injectDown(Protocol.dumps(msg));
|
||||
Thread.sleep(100);
|
||||
assertEquals(1, delivered.size());
|
||||
int ackCount = 0;
|
||||
for (int i = before; i < transport.publishes.size(); i++) {
|
||||
Map<String, Object> f = Protocol.loads(transport.publishes.get(i));
|
||||
if ("ack".equals(String.valueOf(f.get("type")))) {
|
||||
ackCount++;
|
||||
}
|
||||
}
|
||||
assertTrue(ackCount >= 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void localBodyTooLarge() {
|
||||
connectOnline();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < client.getLimits().maxBodyBytes + 1; i++) {
|
||||
sb.append('x');
|
||||
}
|
||||
try {
|
||||
client.sendSync(new Target("endpoint", "ep2"), new Body(sb.toString()), new SendOptions());
|
||||
fail("expected body_too_large");
|
||||
} catch (NixMsgException e) {
|
||||
assertEquals("body_too_large", e.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void localFrameTooLarge() {
|
||||
transport = new FakeTransport();
|
||||
transport.autoHello.put("max_frame_bytes", 200);
|
||||
client = new Client(transport);
|
||||
client.connectSync("ws://example.test/mqtt", "ep1", "pw", null, false);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 40; i++) {
|
||||
sb.append("hello world ");
|
||||
}
|
||||
try {
|
||||
client.sendSync(new Target("endpoint", "ep2"), new Body(sb.toString()), new SendOptions());
|
||||
fail("expected frame_too_large");
|
||||
} catch (NixMsgException e) {
|
||||
assertEquals("frame_too_large", e.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resendKeepsSendAtMs() {
|
||||
transport = new FakeTransport();
|
||||
transport.autoSendOk = false;
|
||||
final AtomicInteger rateHits = new AtomicInteger();
|
||||
transport.onUp(new java.util.function.Function<Map<String, Object>, Map<String, Object>>() {
|
||||
@Override
|
||||
public Map<String, Object> apply(Map<String, Object> frame) {
|
||||
if (!"send".equals(String.valueOf(frame.get("type")))) {
|
||||
return null;
|
||||
}
|
||||
if (rateHits.getAndIncrement() == 0) {
|
||||
Map<String, Object> err = new LinkedHashMap<String, Object>();
|
||||
err.put("code", "rate_limited");
|
||||
err.put("message", "slow");
|
||||
Map<String, Object> resp = new LinkedHashMap<String, Object>();
|
||||
resp.put("v", 1);
|
||||
resp.put("type", "resp");
|
||||
resp.put("rid", frame.get("rid"));
|
||||
resp.put("ok", false);
|
||||
resp.put("error", err);
|
||||
return resp;
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("id", frame.get("id"));
|
||||
data.put("send_at_ms", frame.get("send_at_ms"));
|
||||
data.put("state", "scheduled");
|
||||
Map<String, Object> resp = new LinkedHashMap<String, Object>();
|
||||
resp.put("v", 1);
|
||||
resp.put("type", "resp");
|
||||
resp.put("rid", frame.get("rid"));
|
||||
resp.put("ok", true);
|
||||
resp.put("data", data);
|
||||
return resp;
|
||||
}
|
||||
});
|
||||
client = new Client(transport);
|
||||
client.connectSync("ws://example.test/mqtt", "ep1", "pw", null, false);
|
||||
SendOptions opt = new SendOptions();
|
||||
opt.sendAtMs = 1700000000000L;
|
||||
opt.messageId = "fixed-id-1";
|
||||
SendResult result = client.sendSync(new Target("endpoint", "ep2"), new Body("hi"), opt);
|
||||
assertEquals("fixed-id-1", result.id);
|
||||
int sendCount = 0;
|
||||
for (byte[] raw : transport.publishes) {
|
||||
Map<String, Object> f = Protocol.loads(raw);
|
||||
if ("send".equals(String.valueOf(f.get("type")))) {
|
||||
sendCount++;
|
||||
assertEquals("fixed-id-1", String.valueOf(f.get("id")));
|
||||
assertEquals(1700000000000L, ((Number) f.get("send_at_ms")).longValue());
|
||||
}
|
||||
}
|
||||
assertTrue(sendCount >= 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sessionInvalidStopsReconnect() throws Exception {
|
||||
transport = new FakeTransport();
|
||||
client = new Client(transport);
|
||||
client.connectSync("ws://example.test/mqtt", "ep1", "pw", null, false);
|
||||
transport.nextConnackFail = "bad_credentials";
|
||||
transport.simulateNetworkDrop();
|
||||
long deadline = System.currentTimeMillis() + 3000;
|
||||
while (client.getState() != ConnectionState.AUTH_FAILED && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertEquals(ConnectionState.AUTH_FAILED, client.getState());
|
||||
int n = transport.connects.size();
|
||||
Thread.sleep(500);
|
||||
assertEquals(n, transport.connects.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deliveredDuplicateIgnored() throws Exception {
|
||||
transport = new FakeTransport();
|
||||
client = new Client(transport, false, Types.DEFAULT_MAX_FRAME, Types.CLIENT_NAME, Types.CONNECT_TIMEOUT_MS);
|
||||
client.connectSync("ws://example.test/mqtt", "ep1", "pw", null, false);
|
||||
final CountDownLatch started = new CountDownLatch(1);
|
||||
final CountDownLatch release = new CountDownLatch(1);
|
||||
final List<String> seen = new ArrayList<String>();
|
||||
client.onMessage(new java.util.function.Consumer<IncomingMessage>() {
|
||||
@Override
|
||||
public void accept(IncomingMessage m) {
|
||||
seen.add(m.id);
|
||||
started.countDown();
|
||||
try {
|
||||
release.await(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
});
|
||||
Map<String, Object> msg = new LinkedHashMap<String, Object>();
|
||||
msg.put("v", 1);
|
||||
msg.put("type", "msg");
|
||||
msg.put("id", "m2");
|
||||
msg.put("from", "peer");
|
||||
Map<String, Object> to = new LinkedHashMap<String, Object>();
|
||||
to.put("kind", "endpoint");
|
||||
to.put("id", "ep1");
|
||||
msg.put("to", to);
|
||||
Map<String, Object> body = new LinkedHashMap<String, Object>();
|
||||
body.put("enc", "utf8");
|
||||
body.put("data", "x");
|
||||
msg.put("body", body);
|
||||
msg.put("send_at_ms", 1);
|
||||
final byte[] raw = Protocol.dumps(msg);
|
||||
Thread t = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
transport.injectDown(raw);
|
||||
}
|
||||
});
|
||||
t.start();
|
||||
assertTrue(started.await(2, TimeUnit.SECONDS));
|
||||
transport.injectDown(raw);
|
||||
release.countDown();
|
||||
t.join(2000);
|
||||
Thread.sleep(50);
|
||||
assertEquals(1, seen.size());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user