zmt
3 years ago
14 changed files with 1049 additions and 19 deletions
-
0uniplugin_module/.gitignore
-
4uniplugin_module/build.gradle
-
0uniplugin_module/proguard-rules.pro
-
17uniplugin_module/src/main/AndroidManifest.xml
-
455uniplugin_module/src/main/java/io/dcloud/uniplugin/MainActivity.java
-
4uniplugin_module/src/main/java/io/dcloud/uniplugin/NativePageActivity.java
-
4uniplugin_module/src/main/java/io/dcloud/uniplugin/TestModule.java
-
49uniplugin_module/src/main/java/io/dcloud/uniplugin/mqtt/MQTTRequest.java
-
403uniplugin_module/src/main/java/io/dcloud/uniplugin/mqtt/MQTTSample.java
-
84uniplugin_module/src/main/res/layout/activity_main.xml
-
0uniplugin_module/src/main/res/values/strings.xml
-
33zmt_module/src/main/java/io/dcloud/zmt_module/TcpServer.java
-
8zmt_module/src/main/java/io/dcloud/zmt_module/Zmt_AppProxy.java
-
7zmt_module/src/main/java/io/dcloud/zmt_module/zmtClass.java
@ -1,7 +1,12 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||
package="uni.dcloud.io.uniplugin_module" > |
|
||||
<application> |
|
||||
<activity android:name="io.dcloud.uniplugin.NativePageActivity" |
|
||||
android:theme="@android:style/Theme.DeviceDefault.Light.NoActionBar"></activity> |
|
||||
</application> |
|
||||
</manifest> |
|
||||
|
package="uni.dcloud.io.uniplugin_module"> |
||||
|
|
||||
|
<application> |
||||
|
<activity android:name="io.dcloud.uniplugin.MainActivity"></activity> |
||||
|
<activity |
||||
|
android:name="io.dcloud.uniplugin.NativePageActivity" |
||||
|
android:theme="@android:style/Theme.DeviceDefault.Light.NoActionBar" /> |
||||
|
</application> |
||||
|
|
||||
|
</manifest> |
@ -0,0 +1,455 @@ |
|||||
|
package io.dcloud.uniplugin; |
||||
|
|
||||
|
|
||||
|
import android.app.Activity; |
||||
|
import android.content.Context; |
||||
|
import android.content.res.AssetManager; |
||||
|
import android.os.Bundle; |
||||
|
import android.os.Environment; |
||||
|
import android.util.Log; |
||||
|
import android.view.View; |
||||
|
import android.widget.Button; |
||||
|
import android.widget.TextView; |
||||
|
import android.widget.Toast; |
||||
|
|
||||
|
import com.tencent.iot.hub.device.android.core.util.TXLog; |
||||
|
import com.tencent.iot.hub.device.java.core.common.Status; |
||||
|
import com.tencent.iot.hub.device.java.core.log.TXMqttLogCallBack; |
||||
|
import com.tencent.iot.hub.device.java.core.mqtt.TXMqttActionCallBack; |
||||
|
|
||||
|
import org.eclipse.paho.client.mqttv3.IMqttToken; |
||||
|
import org.eclipse.paho.client.mqttv3.MqttMessage; |
||||
|
|
||||
|
import java.io.BufferedReader; |
||||
|
import java.io.BufferedWriter; |
||||
|
import java.io.File; |
||||
|
import java.io.FileReader; |
||||
|
import java.io.FileWriter; |
||||
|
import java.io.IOException; |
||||
|
import java.io.InputStreamReader; |
||||
|
import java.util.Arrays; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.Map; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
|
||||
|
import io.dcloud.uniplugin.mqtt.MQTTRequest; |
||||
|
import io.dcloud.uniplugin.mqtt.MQTTSample; |
||||
|
import uni.dcloud.io.uniplugin_module.R; |
||||
|
|
||||
|
public class MainActivity extends Activity { |
||||
|
|
||||
|
private Context mContext; |
||||
|
|
||||
|
// Default testing parameters |
||||
|
private String mBrokerURL = null; //传入null,即使用腾讯云物联网通信默认地址 "${ProductId}.iotcloud.tencentdevices.com:8883" https://cloud.tencent.com/document/product/634/32546 |
||||
|
private String mProductID = "BE8N7UZ7OF";// |
||||
|
private String mDevName = "pad20220105";//BuildConfig.DEVICE_NAME; |
||||
|
private String mDevPSK = "7udrYcfTVThbzdMlLT9fHQ==";//BuildConfig.DEVICE_PSK; //若使用证书验证,设为null |
||||
|
private String mSubProductID = null;//BuildConfig.SUB_PRODUCT_ID; // If you wont test gateway, let this to be null |
||||
|
private String mSubDevName = null;//BuildConfig.SUB_DEV_NAME; |
||||
|
private String mSubDevPsk = null;//BuildConfig.SUB_DEVICE_PSK; |
||||
|
private String mTestTopic = "BE8N7UZ7OF/pad20220105/data";//BuildConfig.TEST_TOPIC; // productID/DeviceName/TopicName |
||||
|
private String mDevCertName = null;//"YOUR_DEVICE_NAME_cert.crt"; |
||||
|
private String mDevKeyName = null;//"YOUR_DEVICE_NAME_private.key"; |
||||
|
private String mProductKey = null;//BuildConfig.PRODUCT_KEY; // Used for dynamic register |
||||
|
private String mDevCert = ""; // Cert String |
||||
|
private String mDevPriv = ""; // Priv String |
||||
|
|
||||
|
|
||||
|
private static final String TAG = "TXMQTT"; |
||||
|
private MQTTSample mMQTTSample; |
||||
|
private TextView mLogInfoText; |
||||
|
private volatile boolean mIsConnected; //是否连接 |
||||
|
|
||||
|
private Button statusBtn, connectBtn, disconnectBtn, subscribeBtn, unSubscribeBtn, publishBtn, clearBtn; |
||||
|
|
||||
|
|
||||
|
/**日志保存的路径*/ |
||||
|
private final static String mLogPath = Environment.getExternalStorageDirectory().getPath() + "/tencent/"; |
||||
|
|
||||
|
private AtomicInteger temperature = new AtomicInteger(0); |
||||
|
|
||||
|
|
||||
|
@Override |
||||
|
protected void onCreate(Bundle savedInstanceState) { |
||||
|
super.onCreate(savedInstanceState); |
||||
|
setContentView(R.layout.activity_main); |
||||
|
|
||||
|
mContext = this; |
||||
|
|
||||
|
mLogInfoText =(TextView)findViewById(R.id.tv1); |
||||
|
statusBtn = (Button)findViewById(R.id.btn1); |
||||
|
connectBtn = (Button)findViewById(R.id.btn2); |
||||
|
disconnectBtn = (Button)findViewById(R.id.btn3); |
||||
|
subscribeBtn = (Button)findViewById(R.id.btn4); |
||||
|
unSubscribeBtn = (Button)findViewById(R.id.btn5); |
||||
|
publishBtn = (Button)findViewById(R.id.btn6); |
||||
|
clearBtn = (Button)findViewById(R.id.btn7); |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
statusBtn.setOnClickListener(new View.OnClickListener() { |
||||
|
@Override |
||||
|
public void onClick(View v) { |
||||
|
if(mIsConnected){ |
||||
|
Log.d(TAG, "连接成功"); |
||||
|
Toast.makeText(mContext,"连接成功",Toast.LENGTH_LONG).show(); |
||||
|
}else { |
||||
|
Log.d(TAG, "连接失败"); |
||||
|
Toast.makeText(MainActivity.this,"连接失败",Toast.LENGTH_LONG).show(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
connectBtn.setOnClickListener(new View.OnClickListener() { |
||||
|
@Override |
||||
|
public void onClick(View v) { |
||||
|
getConnect(); |
||||
|
} |
||||
|
}); |
||||
|
disconnectBtn.setOnClickListener(new View.OnClickListener() { |
||||
|
@Override |
||||
|
public void onClick(View v) { |
||||
|
closeConnection(); |
||||
|
} |
||||
|
}); |
||||
|
subscribeBtn.setOnClickListener(new View.OnClickListener() { |
||||
|
@Override |
||||
|
public void onClick(View v) { |
||||
|
getSubscribe(); |
||||
|
} |
||||
|
}); |
||||
|
unSubscribeBtn.setOnClickListener(new View.OnClickListener() { |
||||
|
@Override |
||||
|
public void onClick(View v) { |
||||
|
getUnSubscribe(); |
||||
|
} |
||||
|
}); |
||||
|
publishBtn.setOnClickListener(new View.OnClickListener() { |
||||
|
@Override |
||||
|
public void onClick(View v) { |
||||
|
getPublish(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
clearBtn.setOnClickListener(new View.OnClickListener() { |
||||
|
@Override |
||||
|
public void onClick(View v) { |
||||
|
mLogInfoText.setText(""); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
|
||||
|
//MQTT 认证连接 |
||||
|
public void getConnect(){ |
||||
|
mMQTTSample = new MQTTSample(mContext, new SelfMqttActionCallBack(), mBrokerURL, mProductID, mDevName, mDevPSK, |
||||
|
mDevCert, mDevPriv, mSubProductID, mSubDevName, mTestTopic, null, null, true, new SelfMqttLogCallBack()); |
||||
|
mMQTTSample.setSubDevPsk(mSubDevPsk); |
||||
|
mMQTTSample.connect(); |
||||
|
} |
||||
|
|
||||
|
//断开 MQTT 连接 |
||||
|
public void closeConnection() { |
||||
|
if (mMQTTSample == null) |
||||
|
return; |
||||
|
mMQTTSample.disconnect(); |
||||
|
} |
||||
|
|
||||
|
//订阅主题 |
||||
|
public void getSubscribe(){ |
||||
|
// 在腾讯云控制台增加自定义主题(权限为订阅和发布):custom_data,用于接收IoT服务端转发的自定义数据。 |
||||
|
// 本例中,发布的自定义数据,IoT服务端会在发给当前设备。 |
||||
|
if (mMQTTSample == null) |
||||
|
return; |
||||
|
mMQTTSample.subscribeTopic(); |
||||
|
} |
||||
|
|
||||
|
//取消订阅主题 |
||||
|
public void getUnSubscribe(){ |
||||
|
// 在腾讯云控制台增加自定义主题(权限为订阅和发布):custom_data,用于接收IoT服务端转发的自定义数据。 |
||||
|
// 本例中,发布的自定义数据,IoT服务端会在发给当前设备。 |
||||
|
if (mMQTTSample == null) |
||||
|
return; |
||||
|
mMQTTSample.unSubscribeTopic(); |
||||
|
} |
||||
|
|
||||
|
// 发布主题 |
||||
|
public void getPublish(){ |
||||
|
if (mMQTTSample == null)return; |
||||
|
|
||||
|
// 要发布的数据 |
||||
|
Map<String, String> data = new HashMap<String, String>(); |
||||
|
// 车辆类型 |
||||
|
data.put("car_type", "suv"); |
||||
|
// 车辆油耗 |
||||
|
data.put("oil_consumption", "6.6"); |
||||
|
// 车辆最高速度 |
||||
|
data.put("maximum_speed", "205"); |
||||
|
// 温度信息 |
||||
|
data.put("temperature", String.valueOf(temperature.getAndIncrement())); |
||||
|
|
||||
|
// 需先在腾讯云控制台,增加自定义主题: data,用于更新自定义数据 |
||||
|
mMQTTSample.publishTopic("data", data); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 实现TXMqttActionCallBack回调接口 |
||||
|
*/ |
||||
|
private class SelfMqttActionCallBack extends TXMqttActionCallBack { |
||||
|
|
||||
|
@Override |
||||
|
public void onConnectCompleted(Status status, boolean reconnect, Object userContext, String msg, Throwable cause) { |
||||
|
//MQTT Connect完成回调 |
||||
|
String userContextInfo = ""; |
||||
|
if (userContext instanceof MQTTRequest) { |
||||
|
userContextInfo = userContext.toString(); |
||||
|
} |
||||
|
String logInfo = String.format("onConnectCompleted, status[%s], reconnect[%b], userContext[%s], msg[%s]", |
||||
|
status.name(), reconnect, userContextInfo, msg); |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText, TXLog.LEVEL_INFO); |
||||
|
mIsConnected = true; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onConnectionLost(Throwable cause) { |
||||
|
//MQTT连接断开回调 |
||||
|
String logInfo = String.format("onConnectionLost, cause[%s]", cause.toString()); |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText, TXLog.LEVEL_INFO); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDisconnectCompleted(Status status, Object userContext, String msg, Throwable cause) { |
||||
|
//MQTT Disconnect完成回调 |
||||
|
String userContextInfo = ""; |
||||
|
if (userContext instanceof MQTTRequest) { |
||||
|
userContextInfo = userContext.toString(); |
||||
|
} |
||||
|
String logInfo = String.format("onDisconnectCompleted, status[%s], userContext[%s], msg[%s]", status.name(), userContextInfo, msg); |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText, TXLog.LEVEL_INFO); |
||||
|
mIsConnected = false; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onPublishCompleted(Status status, IMqttToken token, Object userContext, String errMsg, Throwable cause) { |
||||
|
// 发布消息完成回调 |
||||
|
String userContextInfo = ""; |
||||
|
if (userContext instanceof MQTTRequest) { |
||||
|
userContextInfo = userContext.toString(); |
||||
|
} |
||||
|
String logInfo = String.format("onPublishCompleted, status[%s], topics[%s], userContext[%s], errMsg[%s]", |
||||
|
status.name(), Arrays.toString(token.getTopics()), userContextInfo, errMsg); |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText,-1); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onSubscribeCompleted(Status status, IMqttToken asyncActionToken, Object userContext, String errMsg, Throwable cause) { |
||||
|
//订阅主题完成的回调 |
||||
|
String userContextInfo = ""; |
||||
|
if (userContext instanceof MQTTRequest) { |
||||
|
userContextInfo = userContext.toString(); |
||||
|
} |
||||
|
String logInfo = String.format("onSubscribeCompleted, status[%s], topics[%s], userContext[%s], errMsg[%s]", |
||||
|
status.name(), Arrays.toString(asyncActionToken.getTopics()), userContextInfo, errMsg); |
||||
|
if (Status.ERROR == status) { |
||||
|
//失败的 |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText, TXLog.LEVEL_ERROR); |
||||
|
Log.d(TAG, "订阅主题--失败的:"+ logInfo ); |
||||
|
} else { |
||||
|
//成功的 |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText, -1); |
||||
|
Log.d(TAG, "订阅主题--成功的:"+ logInfo ); |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onUnSubscribeCompleted(Status status, IMqttToken asyncActionToken, Object userContext, String errMsg, Throwable cause) { |
||||
|
//取消订阅主题完成回调 |
||||
|
String userContextInfo = ""; |
||||
|
if (userContext instanceof MQTTRequest) { |
||||
|
userContextInfo = userContext.toString(); |
||||
|
} |
||||
|
String logInfo = String.format("onUnSubscribeCompleted, status[%s], topics[%s], userContext[%s], errMsg[%s]", |
||||
|
status.name(), Arrays.toString(asyncActionToken.getTopics()), userContextInfo, errMsg); |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText,-1); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onMessageReceived(final String topic, final MqttMessage message) { |
||||
|
// 收到订阅主题的消息Push 即收到云中控下发的消息 |
||||
|
String logInfo = String.format("receive command, topic[%s], message[%s]", topic, message.toString()); |
||||
|
printLogInfo(TAG, logInfo, mLogInfoText, -1); |
||||
|
|
||||
|
Log.d(TAG, "收到订阅主题的消息: "+ message.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 打印日志信息 |
||||
|
* |
||||
|
* @param logInfo |
||||
|
*/ |
||||
|
protected void printLogInfo(final String tag, final String logInfo, final TextView textView, int logLevel) { |
||||
|
switch (logLevel) { |
||||
|
case TXLog.LEVEL_DEBUG: |
||||
|
TXLog.d(tag, logInfo); |
||||
|
break; |
||||
|
|
||||
|
case TXLog.LEVEL_INFO: |
||||
|
TXLog.i(tag, logInfo); |
||||
|
break; |
||||
|
|
||||
|
case TXLog.LEVEL_ERROR: |
||||
|
TXLog.e(tag, logInfo); |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
TXLog.d(tag, logInfo); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
runOnUiThread(new Runnable() { |
||||
|
@Override |
||||
|
public void run() { |
||||
|
textView.append("==> " + logInfo + "\n\n"); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 实现TXMqttLogCallBack回调接口 |
||||
|
*/ |
||||
|
private class SelfMqttLogCallBack extends TXMqttLogCallBack { |
||||
|
|
||||
|
@Override |
||||
|
public String setSecretKey() { |
||||
|
String secertKey; |
||||
|
if (mDevPSK != null && mDevPSK.length() != 0) { //密钥认证 |
||||
|
secertKey = mDevPSK; |
||||
|
secertKey = secertKey.length() > 24 ? secertKey.substring(0,24) : secertKey; |
||||
|
return secertKey; |
||||
|
} else { |
||||
|
StringBuilder builder = new StringBuilder(); |
||||
|
if (mDevPriv != null && mDevPriv.length() != 0) { //动态注册, 从DevPriv中读取 |
||||
|
builder.append(mDevPriv); |
||||
|
} else { //证书认证,从证书文件中读取 |
||||
|
AssetManager assetManager = mContext.getAssets(); |
||||
|
if (assetManager == null) { |
||||
|
return null; |
||||
|
} |
||||
|
BufferedReader reader = null; |
||||
|
try { |
||||
|
reader = new BufferedReader(new InputStreamReader(assetManager.open(mDevKeyName))); |
||||
|
String str; |
||||
|
while((str = reader.readLine()) != null){ |
||||
|
builder.append(str); |
||||
|
} |
||||
|
} catch (IOException e) { |
||||
|
printLogInfo(TAG, "Get Private Key failed, cannot open Private Key Files.",mLogInfoText, -1); |
||||
|
return null; |
||||
|
} finally { |
||||
|
if (reader != null) { |
||||
|
try { |
||||
|
reader.close(); |
||||
|
} catch (IOException e) { |
||||
|
e.printStackTrace(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
String privateKey = builder.toString(); |
||||
|
if (privateKey.contains("-----BEGIN PRIVATE KEY-----")) { |
||||
|
secertKey = privateKey; |
||||
|
} else { |
||||
|
secertKey = null; |
||||
|
printLogInfo(TAG,"Invaild Private Key File.", mLogInfoText,-1); |
||||
|
} |
||||
|
} |
||||
|
return secertKey; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void printDebug(String message){ |
||||
|
printLogInfo(TAG, message, mLogInfoText, -1); |
||||
|
//TXLog.d(TAG,message); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean saveLogOffline(String log){ |
||||
|
//判断SD卡是否可用 |
||||
|
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { |
||||
|
printLogInfo(TAG, "saveLogOffline not ready", mLogInfoText, -1); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
String logFilePath = mLogPath + mProductID + mDevName + ".log"; |
||||
|
|
||||
|
TXLog.i(TAG, "Save log to %s", logFilePath); |
||||
|
|
||||
|
try { |
||||
|
BufferedWriter wLog = new BufferedWriter(new FileWriter(new File(logFilePath), true)); |
||||
|
wLog.write(log); |
||||
|
wLog.flush(); |
||||
|
wLog.close(); |
||||
|
return true; |
||||
|
} catch (IOException e) { |
||||
|
String logInfo = String.format("Save log to [%s] failed, check the Storage permission!", logFilePath); |
||||
|
printLogInfo(TAG,logInfo, mLogInfoText, -1); |
||||
|
e.printStackTrace(); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String readOfflineLog(){ |
||||
|
//判断SD卡是否可用 |
||||
|
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { |
||||
|
printLogInfo(TAG, "readOfflineLog not ready", mLogInfoText, -1); |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
String logFilePath = mLogPath + mProductID + mDevName + ".log"; |
||||
|
|
||||
|
TXLog.i(TAG, "Read log from %s", logFilePath); |
||||
|
|
||||
|
try { |
||||
|
BufferedReader logReader = new BufferedReader(new FileReader(logFilePath)); |
||||
|
StringBuilder offlineLog = new StringBuilder(); |
||||
|
int data; |
||||
|
while (( data = logReader.read()) != -1 ) { |
||||
|
offlineLog.append((char)data); |
||||
|
} |
||||
|
logReader.close(); |
||||
|
return offlineLog.toString(); |
||||
|
} catch (IOException e) { |
||||
|
e.printStackTrace(); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean delOfflineLog(){ |
||||
|
|
||||
|
//判断SD卡是否可用 |
||||
|
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { |
||||
|
printLogInfo(TAG, "delOfflineLog not ready", mLogInfoText, -1); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
String logFilePath = mLogPath + mProductID + mDevName + ".log"; |
||||
|
|
||||
|
File file = new File(logFilePath); |
||||
|
if (file.exists() && file.isFile()) { |
||||
|
if (file.delete()) { |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
} |
@ -0,0 +1,49 @@ |
|||||
|
package io.dcloud.uniplugin.mqtt; |
||||
|
|
||||
|
|
||||
|
public class MQTTRequest { |
||||
|
|
||||
|
private static final String TAG = MQTTRequest.class.getSimpleName(); |
||||
|
|
||||
|
/** |
||||
|
* 请求类型 |
||||
|
*/ |
||||
|
private String requestType = ""; |
||||
|
|
||||
|
/** |
||||
|
* 请求ID |
||||
|
*/ |
||||
|
private int requestId = 0; |
||||
|
|
||||
|
public MQTTRequest() { |
||||
|
} |
||||
|
|
||||
|
public MQTTRequest(String requestType, int requestId) { |
||||
|
this.requestType = requestType; |
||||
|
this.requestId = requestId; |
||||
|
} |
||||
|
|
||||
|
public String getRequestType() { |
||||
|
return requestType; |
||||
|
} |
||||
|
|
||||
|
public void setRequestType(String requestType) { |
||||
|
this.requestType = requestType; |
||||
|
} |
||||
|
|
||||
|
public int getRequestId() { |
||||
|
return requestId; |
||||
|
} |
||||
|
|
||||
|
public void setRequestId(int requestId) { |
||||
|
this.requestId = requestId; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String toString() { |
||||
|
return "MQTTRequest{" + |
||||
|
"requestType='" + requestType + '\'' + |
||||
|
", requestId=" + requestId + |
||||
|
'}'; |
||||
|
} |
||||
|
} |
@ -0,0 +1,403 @@ |
|||||
|
package io.dcloud.uniplugin.mqtt; |
||||
|
|
||||
|
import android.content.Context; |
||||
|
import android.os.Environment; |
||||
|
import android.util.Log; |
||||
|
|
||||
|
import com.tencent.iot.hub.device.android.core.gateway.TXGatewayConnection; |
||||
|
import com.tencent.iot.hub.device.android.core.util.AsymcSslUtils; |
||||
|
import com.tencent.iot.hub.device.android.core.util.TXLog; |
||||
|
import com.tencent.iot.hub.device.java.core.log.TXMqttLogCallBack; |
||||
|
import com.tencent.iot.hub.device.java.core.mqtt.TXMqttActionCallBack; |
||||
|
import com.tencent.iot.hub.device.java.core.mqtt.TXMqttConstants; |
||||
|
import com.tencent.iot.hub.device.java.core.mqtt.TXOTACallBack; |
||||
|
import com.tencent.iot.hub.device.java.core.mqtt.TXOTAConstansts; |
||||
|
|
||||
|
import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions; |
||||
|
import org.eclipse.paho.client.mqttv3.MqttConnectOptions; |
||||
|
import org.eclipse.paho.client.mqttv3.MqttMessage; |
||||
|
import org.json.JSONException; |
||||
|
import org.json.JSONObject; |
||||
|
|
||||
|
import java.io.ByteArrayInputStream; |
||||
|
import java.util.Map; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
|
||||
|
|
||||
|
public class MQTTSample { |
||||
|
|
||||
|
private static final String TAG = "TXMQTT"; |
||||
|
// Default Value, should be changed in testing |
||||
|
private String mBrokerURL = null; //传入null,即使用腾讯云物联网通信默认地址 "${ProductId}.iotcloud.tencentdevices.com:8883" https://cloud.tencent.com/document/product/634/32546 |
||||
|
private String mProductID = "PRODUCT-ID"; |
||||
|
private String mDevName = "DEVICE-NAME"; |
||||
|
private String mDevPSK = "DEVICE-SECRET"; |
||||
|
|
||||
|
private String mDevCertName = "DEVICE_CERT-NAME "; |
||||
|
private String mDevKeyName = "DEVICE_KEY-NAME "; |
||||
|
|
||||
|
private String mSubProductID = "SUBDEV_PRODUCT-ID"; |
||||
|
private String mSubDevName = "SUBDEV_DEV-NAME"; |
||||
|
private String mSubDevPsk = "SUBDEV_DEVICE-SECRET"; |
||||
|
private String mTestTopic = "TEST_TOPIC_WITH_SUB_PUB"; |
||||
|
private String mDevCert; |
||||
|
private String mDevPriv; |
||||
|
|
||||
|
private boolean mMqttLogFlag; |
||||
|
private TXMqttLogCallBack mMqttLogCallBack; |
||||
|
|
||||
|
private Context mContext; |
||||
|
private String path2Store = ""; |
||||
|
|
||||
|
private TXMqttActionCallBack mMqttActionCallBack; |
||||
|
|
||||
|
/** |
||||
|
* MQTT连接实例 |
||||
|
*/ |
||||
|
private TXGatewayConnection mMqttConnection; |
||||
|
|
||||
|
/** |
||||
|
* 请求ID |
||||
|
*/ |
||||
|
private static AtomicInteger requestID = new AtomicInteger(0); |
||||
|
|
||||
|
public MQTTSample(Context context, TXMqttLogCallBack logCallBack, TXMqttActionCallBack callBack) { |
||||
|
mContext = context; |
||||
|
mMqttActionCallBack = callBack; |
||||
|
} |
||||
|
|
||||
|
public MQTTSample(Context context, TXMqttActionCallBack callBack, String brokerURL, String productId, |
||||
|
String devName, String devPSK, String subProductID, String subDevName, String testTopic, String devCertName, String devKeyName, |
||||
|
Boolean mqttLogFlag, TXMqttLogCallBack logCallBack) { |
||||
|
mBrokerURL = brokerURL; |
||||
|
mProductID = productId; |
||||
|
mDevName = devName; |
||||
|
mDevPSK = devPSK; |
||||
|
mSubProductID = subProductID; |
||||
|
mSubDevName = subDevName; |
||||
|
mTestTopic = testTopic; |
||||
|
mDevCertName = devCertName; |
||||
|
mDevKeyName = devKeyName; |
||||
|
|
||||
|
mMqttLogFlag = mqttLogFlag; |
||||
|
mMqttLogCallBack = logCallBack; |
||||
|
|
||||
|
mContext = context; |
||||
|
mMqttActionCallBack = callBack; |
||||
|
} |
||||
|
|
||||
|
public MQTTSample(Context context, TXMqttActionCallBack callBack, String brokerURL, String productId, |
||||
|
String devName, String devPsk, String devCert, String devPriv, String subProductID, String subDevName, String testTopic, String devCertName, String devKeyName, |
||||
|
Boolean mqttLogFlag, TXMqttLogCallBack logCallBack) { |
||||
|
mBrokerURL = brokerURL; |
||||
|
mProductID = productId; |
||||
|
mDevName = devName; |
||||
|
mDevPSK = devPsk; |
||||
|
mDevCert = devCert; |
||||
|
mDevPriv = devPriv; |
||||
|
mSubProductID = subProductID; |
||||
|
mSubDevName = subDevName; |
||||
|
mTestTopic = testTopic; |
||||
|
mDevCertName = devCertName; |
||||
|
mDevKeyName = devKeyName; |
||||
|
|
||||
|
mMqttLogFlag = mqttLogFlag; |
||||
|
mMqttLogCallBack = logCallBack; |
||||
|
|
||||
|
mContext = context; |
||||
|
mMqttActionCallBack = callBack; |
||||
|
path2Store = mContext.getCacheDir().getAbsolutePath(); |
||||
|
} |
||||
|
|
||||
|
public MQTTSample(Context context, TXMqttActionCallBack callBack, String brokerURL, String productId, |
||||
|
String devName, String devPsk, String devCert, String devPriv, String subProductID, String subDevName, String subDevPsk, String testTopic, String devCertName, String devKeyName, |
||||
|
Boolean mqttLogFlag, TXMqttLogCallBack logCallBack) { |
||||
|
this(context, callBack, brokerURL, productId, devName, devPsk, devCert, devPriv, subProductID, subDevName, testTopic, devCertName, devKeyName, mqttLogFlag, logCallBack); |
||||
|
mSubDevPsk = subDevPsk; |
||||
|
} |
||||
|
|
||||
|
public void setSubDevPsk(String val) { |
||||
|
mSubDevPsk = val; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
public MQTTSample(Context context, TXMqttActionCallBack callBack, String brokerURL, String productId, |
||||
|
String devName, String devPSK, String subProductID, String subDevName, String testTopic) { |
||||
|
mBrokerURL = brokerURL; |
||||
|
mProductID = productId; |
||||
|
mDevName = devName; |
||||
|
mDevPSK = devPSK; |
||||
|
mSubProductID = subProductID; |
||||
|
mSubDevName = subDevName; |
||||
|
mTestTopic = testTopic; |
||||
|
|
||||
|
mContext = context; |
||||
|
mMqttActionCallBack = callBack; |
||||
|
} |
||||
|
|
||||
|
private TXOTACallBack oTACallBack = new TXOTACallBack() { |
||||
|
|
||||
|
@Override |
||||
|
public void onReportFirmwareVersion(int resultCode, String version, String resultMsg) { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean onLastestFirmwareReady(String url, String md5, String version) { |
||||
|
System.out.println("onLastestFirmwareReady url=" + url + " version " + version); |
||||
|
mMqttConnection.gatewayDownSubdevApp(url, path2Store + "/" + md5, md5, version); |
||||
|
return true; // false 自动触发下载升级文件 true 需要手动触发下载升级文件 |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDownloadProgress(int percent, String version) { |
||||
|
mMqttConnection.gatewaySubdevReportProgress(percent, version); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDownloadCompleted(String outputFile, String version) { |
||||
|
mMqttConnection.gatewaySubdevReportStart(version); |
||||
|
mMqttConnection.gatewaySubdevReportSuccess(version); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDownloadFailure(int errCode, String version) { |
||||
|
mMqttConnection.gatewaySubdevReportFail(errCode, "", version); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 建立MQTT连接 |
||||
|
*/ |
||||
|
public void connect() { |
||||
|
mMqttConnection = new TXGatewayConnection(mContext, mBrokerURL, mProductID, mDevName, mDevPSK,null,null ,mMqttLogFlag, mMqttLogCallBack, mMqttActionCallBack); |
||||
|
mMqttConnection.setSubDevName(mSubDevName); |
||||
|
mMqttConnection.setSubDevProductKey(mSubDevPsk); |
||||
|
mMqttConnection.setSubProductID(mSubProductID); |
||||
|
MqttConnectOptions options = new MqttConnectOptions(); |
||||
|
options.setConnectionTimeout(8); |
||||
|
options.setKeepAliveInterval(240); |
||||
|
options.setAutomaticReconnect(true); |
||||
|
|
||||
|
if (mDevPriv != null && mDevCert != null && mDevPriv.length() != 0 && mDevCert.length() != 0) { |
||||
|
TXLog.i(TAG, "Using cert stream " + mDevPriv + " " + mDevCert); |
||||
|
options.setSocketFactory(AsymcSslUtils.getSocketFactoryByStream(new ByteArrayInputStream(mDevCert.getBytes()), new ByteArrayInputStream(mDevPriv.getBytes()))); |
||||
|
} else if (mDevPSK != null && mDevPSK.length() != 0){ |
||||
|
TXLog.i(TAG, "Using PSK"); |
||||
|
// options.setSocketFactory(AsymcSslUtils.getSocketFactory()); 如果您使用的是3.3.0及以下版本的 hub-device-android sdk,由于密钥认证默认配置的ssl://的url,请添加此句setSocketFactory配置。 |
||||
|
} else { |
||||
|
TXLog.i(TAG, "Using cert assets file"); |
||||
|
options.setSocketFactory(AsymcSslUtils.getSocketFactoryByAssetsFile(mContext, mDevCertName, mDevKeyName)); |
||||
|
} |
||||
|
|
||||
|
MQTTRequest mqttRequest = new MQTTRequest("connect", requestID.getAndIncrement()); |
||||
|
mMqttConnection.connect(options, mqttRequest); |
||||
|
|
||||
|
DisconnectedBufferOptions bufferOptions = new DisconnectedBufferOptions(); |
||||
|
bufferOptions.setBufferEnabled(true); |
||||
|
bufferOptions.setBufferSize(1024); |
||||
|
bufferOptions.setDeleteOldestMessages(true); |
||||
|
mMqttConnection.setBufferOpts(bufferOptions); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 断开MQTT连接 |
||||
|
*/ |
||||
|
public void disconnect() { |
||||
|
MQTTRequest mqttRequest = new MQTTRequest("disconnect", requestID.getAndIncrement()); |
||||
|
mMqttConnection.disConnect(mqttRequest); |
||||
|
} |
||||
|
|
||||
|
public void setSubdevOnline() { |
||||
|
// set subdev online |
||||
|
mMqttConnection.gatewaySubdevOnline(mSubProductID, mSubDevName); |
||||
|
} |
||||
|
|
||||
|
public void setSubDevOffline() { |
||||
|
mMqttConnection.gatewaySubdevOffline(mSubProductID, mSubDevName); |
||||
|
} |
||||
|
|
||||
|
public void setSubDevBinded() { |
||||
|
mMqttConnection.gatewayBindSubdev(mSubProductID, mSubDevName, mSubDevPsk); |
||||
|
} |
||||
|
|
||||
|
public void setSubDevUnbinded() { |
||||
|
mMqttConnection.gatewayUnbindSubdev(mSubProductID, mSubDevName); |
||||
|
} |
||||
|
|
||||
|
public void checkSubdevRelation() { |
||||
|
mMqttConnection.getGatewaySubdevRealtion(); |
||||
|
} |
||||
|
|
||||
|
public void getRemoteConfig() { |
||||
|
mMqttConnection.getRemoteConfig(); |
||||
|
} |
||||
|
|
||||
|
public void concernRemoteConfig() { |
||||
|
mMqttConnection.concernConfig(); |
||||
|
} |
||||
|
|
||||
|
public void reportSubDevVersion(String version) { |
||||
|
mMqttConnection.gatewaySubdevReportVer(version); |
||||
|
} |
||||
|
|
||||
|
public void subscribeNTPTopic() { |
||||
|
// QOS等级 |
||||
|
int qos = TXMqttConstants.QOS1; |
||||
|
// 用户上下文(请求实例) |
||||
|
MQTTRequest mqttRequest = new MQTTRequest("subscribeNTPTopic", requestID.getAndIncrement()); |
||||
|
mMqttConnection.subscribeNTPTopic(qos, mqttRequest); |
||||
|
} |
||||
|
|
||||
|
public void getNTPService() { |
||||
|
mMqttConnection.getNTPService(); |
||||
|
} |
||||
|
|
||||
|
public void initOTA() { |
||||
|
TXLog.e(TAG, "path2Store " + path2Store); |
||||
|
mMqttConnection.initOTA(path2Store, oTACallBack); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 订阅广播主题 |
||||
|
*/ |
||||
|
public void subscribeBroadCastTopic() { |
||||
|
// 用户上下文(请求实例) |
||||
|
MQTTRequest mqttRequest = new MQTTRequest("subscribeTopic", requestID.getAndIncrement()); |
||||
|
// 订阅广播主题 |
||||
|
mMqttConnection.subscribeBroadcastTopic(TXMqttConstants.QOS1, mqttRequest); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 订阅主题 |
||||
|
* |
||||
|
*/ |
||||
|
public void subscribeTopic() { |
||||
|
// 主题 |
||||
|
String topic = mTestTopic; |
||||
|
// QOS等级 |
||||
|
int qos = TXMqttConstants.QOS1; |
||||
|
// 用户上下文(请求实例) |
||||
|
MQTTRequest mqttRequest = new MQTTRequest("subscribeTopic", requestID.getAndIncrement()); |
||||
|
|
||||
|
Log.d(TAG, "sub topic is " + topic); |
||||
|
|
||||
|
// 订阅主题 |
||||
|
mMqttConnection.subscribe(topic, qos, mqttRequest); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 取消订阅主题 |
||||
|
* |
||||
|
*/ |
||||
|
public void unSubscribeTopic() { |
||||
|
// 主题 |
||||
|
String topic = mTestTopic; |
||||
|
// 用户上下文(请求实例) |
||||
|
MQTTRequest mqttRequest = new MQTTRequest("unSubscribeTopic", requestID.getAndIncrement()); |
||||
|
Log.d(TAG, "Start to unSubscribe" + topic); |
||||
|
// 取消订阅主题 |
||||
|
mMqttConnection.unSubscribe(topic, mqttRequest); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发布主题 |
||||
|
*/ |
||||
|
public void publishTopic(String topicName, Map<String, String> data) { |
||||
|
// 主题 |
||||
|
String topic = mTestTopic; |
||||
|
// MQTT消息 |
||||
|
MqttMessage message = new MqttMessage(); |
||||
|
|
||||
|
JSONObject jsonObject = new JSONObject(); |
||||
|
try { |
||||
|
for (Map.Entry<String, String> entrys : data.entrySet()) { |
||||
|
jsonObject.put(entrys.getKey(), entrys.getValue()); |
||||
|
} |
||||
|
} catch (JSONException e) { |
||||
|
TXLog.e(TAG, e, "pack json data failed!"); |
||||
|
} |
||||
|
message.setQos(TXMqttConstants.QOS1); |
||||
|
message.setPayload(jsonObject.toString().getBytes()); |
||||
|
|
||||
|
// 用户上下文(请求实例) |
||||
|
MQTTRequest mqttRequest = new MQTTRequest("publishTopic", requestID.getAndIncrement()); |
||||
|
|
||||
|
Log.d(TAG, "pub topic " + topic + message); |
||||
|
// 发布主题 |
||||
|
mMqttConnection.publish(topic, message, mqttRequest); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 订阅RRPC主题 |
||||
|
* |
||||
|
*/ |
||||
|
public void subscribeRRPCTopic() { |
||||
|
// 用户上下文(请求实例) |
||||
|
MQTTRequest mqttRequest = new MQTTRequest("subscribeTopic", requestID.getAndIncrement()); |
||||
|
// 订阅主题 |
||||
|
mMqttConnection.subscribeRRPCTopic(TXMqttConstants.QOS0, mqttRequest); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 生成一条日志 |
||||
|
* @param logLevel 日志级别: |
||||
|
* 错误:TXMqttLogConstants.LEVEL_ERROR |
||||
|
* 警告:TXMqttLogConstants.LEVEL_WARN |
||||
|
* 通知:TXMqttLogConstants.LEVEL_INFO |
||||
|
* 调试:TXMqttLogConstants.LEVEL_DEBUG |
||||
|
* @param tag |
||||
|
* @param format |
||||
|
* @param obj |
||||
|
*/ |
||||
|
public void mLog(int logLevel, final String tag,final String format, final Object... obj) { |
||||
|
if (mMqttLogFlag) |
||||
|
mMqttConnection.mLog(logLevel, tag, format, obj); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发起一次日志上传 |
||||
|
*/ |
||||
|
public void uploadLog() { |
||||
|
mMqttConnection.uploadLog(); |
||||
|
} |
||||
|
|
||||
|
public void checkFirmware() { |
||||
|
|
||||
|
mMqttConnection.initOTA(Environment.getExternalStorageDirectory().getAbsolutePath(), new TXOTACallBack() { |
||||
|
@Override |
||||
|
public void onReportFirmwareVersion(int resultCode, String version, String resultMsg) { |
||||
|
TXLog.e(TAG, "onReportFirmwareVersion:" + resultCode + ", version:" + version + ", resultMsg:" + resultMsg); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean onLastestFirmwareReady(String url, String md5, String version) { |
||||
|
TXLog.e(TAG, "MQTTSample onLastestFirmwareReady"); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDownloadProgress(int percent, String version) { |
||||
|
TXLog.e(TAG, "onDownloadProgress:" + percent); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDownloadCompleted(String outputFile, String version) { |
||||
|
TXLog.e(TAG, "onDownloadCompleted:" + outputFile + ", version:" + version); |
||||
|
|
||||
|
mMqttConnection.reportOTAState(TXOTAConstansts.ReportState.DONE, 0, "OK", version); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDownloadFailure(int errCode, String version) { |
||||
|
TXLog.e(TAG, "onDownloadFailure:" + errCode); |
||||
|
|
||||
|
mMqttConnection.reportOTAState(TXOTAConstansts.ReportState.FAIL, errCode, "FAIL", version); |
||||
|
} |
||||
|
}); |
||||
|
mMqttConnection.reportCurrentFirmwareVersion("0.0.1"); |
||||
|
} |
||||
|
} |
@ -0,0 +1,84 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
|
xmlns:tools="http://schemas.android.com/tools" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="match_parent" |
||||
|
android:orientation="vertical" |
||||
|
tools:context="io.dcloud.uniplugin.MainActivity"> |
||||
|
|
||||
|
<Button |
||||
|
android:id="@+id/btn1" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="连接状态" /> |
||||
|
|
||||
|
<LinearLayout |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:orientation="horizontal"> |
||||
|
<Button |
||||
|
android:id="@+id/btn2" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="连接MQTT" /> |
||||
|
|
||||
|
<Button |
||||
|
android:id="@+id/btn3" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginLeft="20dp" |
||||
|
android:text="断开连接MQTT" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<LinearLayout |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:orientation="horizontal"> |
||||
|
<Button |
||||
|
android:id="@+id/btn4" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="订阅主题" /> |
||||
|
<Button |
||||
|
android:id="@+id/btn5" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginLeft="20dp" |
||||
|
android:text="取消订阅主题" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<LinearLayout |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:orientation="horizontal"> |
||||
|
<Button |
||||
|
android:id="@+id/btn6" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="发布主题" /> |
||||
|
|
||||
|
<Button |
||||
|
android:id="@+id/btn7" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginLeft="20dp" |
||||
|
android:text="清空日志" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
|
||||
|
<TextView |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="日志输出" /> |
||||
|
|
||||
|
<ScrollView |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content"> |
||||
|
<TextView |
||||
|
android:id="@+id/tv1" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="match_parent" /> |
||||
|
</ScrollView> |
||||
|
|
||||
|
</LinearLayout> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue