diff --git a/app/build.gradle b/app/build.gradle index ce5890f8..a9ad0df1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -188,6 +188,9 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) //frpc implementation files('libs/frpclib.aar') + //kmnkt是基于Kotlin Multiplatform的跨平台socket通信统一接口。支持Android目标与JVM目标,支持UDP/TCP/MQTT协议使用同一套接口实现 + implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5") + implementation files('libs/socket.aar') testImplementation deps.junit androidTestImplementation 'androidx.test.ext:junit:1.1.5' diff --git a/app/libs/socket.aar b/app/libs/socket.aar new file mode 100644 index 00000000..708c9844 Binary files /dev/null and b/app/libs/socket.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc956622..b0c483e2 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -282,6 +282,8 @@ -keep class javax.activation.** { *;} -keep class com.smailnet.emailkit.** { *;} -keep class com.idormy.sms.forwarder.utils.mail.** {*;} +-keep class com.gitee.xuankaicat.kmnkt.** {*;} +-keep class org.eclipse.paho.client.** {*;} -keep public class com.xuexiang.xrouter.routes.**{*;} -keep class * implements com.xuexiang.xrouter.facade.template.ISyringe{*;} diff --git a/app/src/main/java/com/idormy/sms/forwarder/database/entity/LogsDetail.kt b/app/src/main/java/com/idormy/sms/forwarder/database/entity/LogsDetail.kt index 5619c359..4e1b94a1 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/database/entity/LogsDetail.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/database/entity/LogsDetail.kt @@ -54,6 +54,7 @@ data class LogsDetail( TYPE_DINGTALK_INNER_ROBOT -> R.drawable.icon_dingtalk_inner TYPE_FEISHU_APP -> R.drawable.icon_feishu_app TYPE_URL_SCHEME -> R.drawable.icon_url_scheme + TYPE_SOCKET -> R.drawable.icon_socket else -> R.drawable.icon_sms } } diff --git a/app/src/main/java/com/idormy/sms/forwarder/database/entity/Sender.kt b/app/src/main/java/com/idormy/sms/forwarder/database/entity/Sender.kt index 4a9a6b26..2627bdaf 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/database/entity/Sender.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/database/entity/Sender.kt @@ -38,6 +38,7 @@ data class Sender( TYPE_DINGTALK_INNER_ROBOT -> R.drawable.icon_dingtalk_inner TYPE_FEISHU_APP -> R.drawable.icon_feishu_app TYPE_URL_SCHEME -> R.drawable.icon_url_scheme + TYPE_SOCKET -> R.drawable.icon_socket else -> R.drawable.icon_sms } @@ -60,6 +61,7 @@ data class Sender( TYPE_DINGTALK_INNER_ROBOT -> R.drawable.icon_dingtalk_inner TYPE_FEISHU_APP -> R.drawable.icon_feishu_app TYPE_URL_SCHEME -> R.drawable.icon_url_scheme + TYPE_SOCKET -> R.drawable.icon_socket else -> R.drawable.icon_sms } diff --git a/app/src/main/java/com/idormy/sms/forwarder/database/ext/ConvertersSenderList.kt b/app/src/main/java/com/idormy/sms/forwarder/database/ext/ConvertersSenderList.kt index cdc09c22..a04eb775 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/database/ext/ConvertersSenderList.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/database/ext/ConvertersSenderList.kt @@ -10,7 +10,7 @@ class ConvertersSenderList { @TypeConverter fun stringToObject(value: String): List { - var senderList: MutableList = mutableListOf() + val senderList: MutableList = mutableListOf() value.split(",").map { it.trim() }.forEach { val sender = Core.sender.getOne(it.toLong()) senderList.add(sender) @@ -20,7 +20,7 @@ class ConvertersSenderList { @TypeConverter fun objectToString(list: List): String { - var senderList = ArrayList() + val senderList = ArrayList() list.forEach { senderList += it.id } diff --git a/app/src/main/java/com/idormy/sms/forwarder/entity/setting/SocketSetting.kt b/app/src/main/java/com/idormy/sms/forwarder/entity/setting/SocketSetting.kt new file mode 100644 index 00000000..2a719498 --- /dev/null +++ b/app/src/main/java/com/idormy/sms/forwarder/entity/setting/SocketSetting.kt @@ -0,0 +1,32 @@ +package com.idormy.sms.forwarder.entity.setting + +import com.idormy.sms.forwarder.R +import java.io.Serializable + +data class SocketSetting( + val method: String? = "MQTT", + var address: String = "", //IP地址 + val port: Int = 0, //端口号 + val msgTemplate: String = "", //消息模板 + val secret: String? = "", //签名密钥 + val username: String = "", //用户名 + val password: String = "", //密码 + val inCharset: String = "", //输入编码 + val outCharset: String = "", //输出编码 + val inMessageTopic: String = "", //Mqtt专属,输入信息响应主题,即接收对应主题的消息 + val outMessageTopic: String = "", //Mqtt专属,输出信息响应主题,即发送对应主题的消息 + val uriType: String = "tcp", //Mqtt专属,通信方式 默认为tcp + val path: String = "", //Mqtt专属,通信路径,用于在使用ws进行通信时设置uri,最后的访问结果为"${uriType}://${address}:${port}${path}" + val clientId: String = "", //Mqtt专属,客户端ID,如果为空则为随机值 +) : Serializable { + + fun getMethodCheckId(): Int { + return when (method) { + null, "MQTT" -> R.id.rb_method_mqtt + "TCP" -> R.id.rb_method_tcp + "UDP" -> R.id.rb_method_udp + else -> R.id.rb_method_mqtt + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/idormy/sms/forwarder/fragment/SendersFragment.kt b/app/src/main/java/com/idormy/sms/forwarder/fragment/SendersFragment.kt index f21c486f..7d7d12f0 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/fragment/SendersFragment.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/fragment/SendersFragment.kt @@ -110,6 +110,7 @@ class SendersFragment : BaseFragment(), SenderPagingAda TYPE_DINGTALK_INNER_ROBOT -> DingtalkInnerRobotFragment::class.java TYPE_FEISHU_APP -> FeishuAppFragment::class.java TYPE_URL_SCHEME -> UrlSchemeFragment::class.java + TYPE_SOCKET -> SocketFragment::class.java else -> DingtalkGroupRobotFragment::class.java } ).setNewActivity(true) @@ -136,6 +137,7 @@ class SendersFragment : BaseFragment(), SenderPagingAda TYPE_DINGTALK_INNER_ROBOT -> DingtalkInnerRobotFragment::class.java TYPE_FEISHU_APP -> FeishuAppFragment::class.java TYPE_URL_SCHEME -> UrlSchemeFragment::class.java + TYPE_SOCKET -> SocketFragment::class.java else -> DingtalkGroupRobotFragment::class.java } ).setNewActivity(true) diff --git a/app/src/main/java/com/idormy/sms/forwarder/fragment/senders/SocketFragment.kt b/app/src/main/java/com/idormy/sms/forwarder/fragment/senders/SocketFragment.kt new file mode 100644 index 00000000..d60e54ab --- /dev/null +++ b/app/src/main/java/com/idormy/sms/forwarder/fragment/senders/SocketFragment.kt @@ -0,0 +1,252 @@ +package com.idormy.sms.forwarder.fragment.senders + +import android.os.Looper +import android.text.TextUtils +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.RadioGroup +import androidx.fragment.app.viewModels +import com.google.gson.Gson +import com.idormy.sms.forwarder.R +import com.idormy.sms.forwarder.core.BaseFragment +import com.idormy.sms.forwarder.database.AppDatabase +import com.idormy.sms.forwarder.database.entity.Sender +import com.idormy.sms.forwarder.database.viewmodel.BaseViewModelFactory +import com.idormy.sms.forwarder.database.viewmodel.SenderViewModel +import com.idormy.sms.forwarder.databinding.FragmentSendersSocketBinding +import com.idormy.sms.forwarder.entity.MsgInfo +import com.idormy.sms.forwarder.entity.setting.SocketSetting +import com.idormy.sms.forwarder.utils.* +import com.idormy.sms.forwarder.utils.sender.SocketUtils +import com.jeremyliao.liveeventbus.LiveEventBus +import com.xuexiang.xaop.annotation.SingleClick +import com.xuexiang.xpage.annotation.Page +import com.xuexiang.xrouter.annotation.AutoWired +import com.xuexiang.xrouter.launcher.XRouter +import com.xuexiang.xui.utils.CountDownButtonHelper +import com.xuexiang.xui.widget.actionbar.TitleBar +import com.xuexiang.xui.widget.dialog.materialdialog.DialogAction +import com.xuexiang.xui.widget.dialog.materialdialog.MaterialDialog +import io.reactivex.SingleObserver +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import java.util.* + +@Page(name = "Socket") +@Suppress("PrivatePropertyName") +class SocketFragment : BaseFragment(), View.OnClickListener { + + private val TAG: String = SocketFragment::class.java.simpleName + var titleBar: TitleBar? = null + private val viewModel by viewModels { BaseViewModelFactory(context) } + private var mCountDownHelper: CountDownButtonHelper? = null + + @JvmField + @AutoWired(name = KEY_SENDER_ID) + var senderId: Long = 0 + + @JvmField + @AutoWired(name = KEY_SENDER_TYPE) + var senderType: Int = 0 + + @JvmField + @AutoWired(name = KEY_SENDER_CLONE) + var isClone: Boolean = false + + override fun initArgs() { + XRouter.getInstance().inject(this) + } + + override fun viewBindingInflate( + inflater: LayoutInflater, + container: ViewGroup, + ): FragmentSendersSocketBinding { + return FragmentSendersSocketBinding.inflate(inflater, container, false) + } + + override fun initTitle(): TitleBar? { + titleBar = super.initTitle()!!.setImmersive(false).setTitle(R.string.socket) + return titleBar + } + + /** + * 初始化控件 + */ + override fun initViews() { + //测试按钮增加倒计时,避免重复点击 + mCountDownHelper = CountDownButtonHelper(binding!!.btnTest, SettingUtils.requestTimeout) + mCountDownHelper!!.setOnCountDownListener(object : CountDownButtonHelper.OnCountDownListener { + override fun onCountDown(time: Int) { + binding!!.btnTest.text = String.format(getString(R.string.seconds_n), time) + } + + override fun onFinished() { + binding!!.btnTest.text = getString(R.string.test) + } + }) + + //新增 + if (senderId <= 0) { + titleBar?.setSubTitle(getString(R.string.add_sender)) + binding!!.btnDel.setText(R.string.discard) + return + } + + //编辑 + binding!!.btnDel.setText(R.string.del) + AppDatabase.getInstance(requireContext()).senderDao().get(senderId).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(object : SingleObserver { + override fun onSubscribe(d: Disposable) {} + + override fun onError(e: Throwable) { + e.printStackTrace() + } + + override fun onSuccess(sender: Sender) { + if (isClone) { + titleBar?.setSubTitle(getString(R.string.clone_sender) + ": " + sender.name) + binding!!.btnDel.setText(R.string.discard) + } else { + titleBar?.setSubTitle(getString(R.string.edit_sender) + ": " + sender.name) + } + binding!!.etName.setText(sender.name) + binding!!.sbEnable.isChecked = sender.status == 1 + val settingVo = Gson().fromJson(sender.jsonSetting, SocketSetting::class.java) + Log.d(TAG, settingVo.toString()) + if (settingVo != null) { + val checkedId = settingVo.getMethodCheckId() + binding!!.rgMethod.check(checkedId) + binding!!.etAddress.setText(settingVo.address) + binding!!.etPort.setText(settingVo.port.toString()) + binding!!.etMsgTemplate.setText(settingVo.msgTemplate) + binding!!.etSecret.setText(settingVo.secret) + binding!!.etUsername.setText(settingVo.username) + binding!!.etPassword.setText(settingVo.password) + binding!!.etInCharset.setSelectedItem(settingVo.inCharset) + binding!!.etOutCharset.setSelectedItem(settingVo.outCharset) + binding!!.etInMessageTopic.setText(settingVo.inMessageTopic) + binding!!.etOutMessageTopic.setText(settingVo.outMessageTopic) + binding!!.etUriType.setText(settingVo.uriType) + binding!!.etPath.setText(settingVo.path) + binding!!.etClientId.setText(settingVo.clientId) + binding!!.layoutMqtt.visibility = if (checkedId == R.id.rb_method_mqtt) View.VISIBLE else View.GONE + } + } + }) + } + + override fun initListeners() { + binding!!.btnTest.setOnClickListener(this) + binding!!.btnDel.setOnClickListener(this) + binding!!.btnSave.setOnClickListener(this) + binding!!.rgMethod.setOnCheckedChangeListener { _: RadioGroup?, checkedId: Int -> + binding!!.layoutMqtt.visibility = if (checkedId == R.id.rb_method_mqtt) View.VISIBLE else View.GONE + } + LiveEventBus.get(KEY_SENDER_TEST, String::class.java).observe(this) { mCountDownHelper?.finish() } + } + + @SingleClick + override fun onClick(v: View) { + try { + when (v.id) { + R.id.btn_test -> { + mCountDownHelper?.start() + Thread { + try { + val settingVo = checkSetting() + Log.d(TAG, settingVo.toString()) + val msgInfo = MsgInfo( + "sms", getString(R.string.test_phone_num), getString(R.string.test_sender_sms), Date(), getString(R.string.test_sim_info) + ) + SocketUtils.sendMsg(settingVo, msgInfo) + } catch (e: Exception) { + e.printStackTrace() + if (Looper.myLooper() == null) Looper.prepare() + XToastUtils.error(e.message.toString()) + Looper.loop() + } + LiveEventBus.get(KEY_SENDER_TEST, String::class.java).post("finish") + }.start() + return + } + R.id.btn_del -> { + if (senderId <= 0 || isClone) { + popToBack() + return + } + + MaterialDialog.Builder(requireContext()).title(R.string.delete_sender_title).content(R.string.delete_sender_tips).positiveText(R.string.lab_yes).negativeText(R.string.lab_no).onPositive { _: MaterialDialog?, _: DialogAction? -> + viewModel.delete(senderId) + XToastUtils.success(R.string.delete_sender_toast) + popToBack() + }.show() + return + } + R.id.btn_save -> { + val name = binding!!.etName.text.toString().trim() + if (TextUtils.isEmpty(name)) { + throw Exception(getString(R.string.invalid_name)) + } + + val status = if (binding!!.sbEnable.isChecked) 1 else 0 + val settingVo = checkSetting() + if (isClone) senderId = 0 + val senderNew = Sender(senderId, senderType, name, Gson().toJson(settingVo), status) + Log.d(TAG, senderNew.toString()) + + viewModel.insertOrUpdate(senderNew) + XToastUtils.success(R.string.tipSaveSuccess) + popToBack() + return + } + } + } catch (e: Exception) { + XToastUtils.error(e.message.toString()) + e.printStackTrace() + } + } + + private fun checkSetting(): SocketSetting { + val address = binding!!.etAddress.text.toString().trim() + if (CommonUtils.checkIP(address) == "Neither" && !CommonUtils.checkDomain(address)) { + throw Exception(getString(R.string.invalid_ip)) + } + + val port = binding!!.etPort.text.toString().trim() + if (!CommonUtils.checkPort(port)) { + throw Exception(getString(R.string.invalid_port)) + } + + val method = when (binding!!.rgMethod.checkedRadioButtonId) { + R.id.rb_method_tcp -> "TCP" + R.id.rb_method_udp -> "UDP" + else -> "MQTT" + } + + val msgTemplate = binding!!.etMsgTemplate.text.toString().trim() + val secret = binding!!.etSecret.text.toString().trim() + val username = binding!!.etUsername.text.toString().trim() + val password = binding!!.etPassword.text.toString().trim() + val inCharset = binding!!.etInCharset.text.toString().trim() + val outCharset = binding!!.etOutCharset.text.toString().trim() + val inMessageTopic = binding!!.etInMessageTopic.text.toString().trim() + val outMessageTopic = binding!!.etOutMessageTopic.text.toString().trim() + val uriType = binding!!.etUriType.text.toString().trim() + val path = binding!!.etPath.text.toString().trim() + val clientId = binding!!.etClientId.text.toString().trim() + + if (method == "MQTT" && (TextUtils.isEmpty(inMessageTopic) || TextUtils.isEmpty(outMessageTopic))) { + throw Exception(getString(R.string.invalid_mqtt_message_topic)) + } + + return SocketSetting(method, address, port.toInt(), msgTemplate, secret, username, password, inCharset, outCharset, inMessageTopic, outMessageTopic, uriType, path, clientId) + } + + override fun onDestroyView() { + if (mCountDownHelper != null) mCountDownHelper!!.recycle() + super.onDestroyView() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/idormy/sms/forwarder/utils/CommonUtils.kt b/app/src/main/java/com/idormy/sms/forwarder/utils/CommonUtils.kt index d1959b5e..f53565d6 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/utils/CommonUtils.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/utils/CommonUtils.kt @@ -60,49 +60,34 @@ class CommonUtils private constructor() { @Suppress("SameParameterValue", "NAME_SHADOWING") @JvmStatic fun showPrivacyDialog(context: Context, submitListener: SingleButtonCallback?): Dialog { - val dialog = - MaterialDialog.Builder(context).title(R.string.title_reminder).autoDismiss(false) - .cancelable(false) - .positiveText(R.string.lab_agree) - .onPositive { dialog1: MaterialDialog, which: DialogAction? -> - if (submitListener != null) { - submitListener.onClick(dialog1, which!!) - } else { - dialog1.dismiss() - } - } - .negativeText(R.string.lab_disagree).onNegative { dialog, _ -> + val dialog = MaterialDialog.Builder(context).title(R.string.title_reminder).autoDismiss(false).cancelable(false).positiveText(R.string.lab_agree).onPositive { dialog1: MaterialDialog, which: DialogAction? -> + if (submitListener != null) { + submitListener.onClick(dialog1, which!!) + } else { + dialog1.dismiss() + } + }.negativeText(R.string.lab_disagree).onNegative { dialog, _ -> + dialog.dismiss() + DialogLoader.getInstance().showConfirmDialog( + context, ResUtils.getString(R.string.title_reminder), String.format( + ResUtils.getString(R.string.content_privacy_explain_again), ResUtils.getString(R.string.app_name) + ), ResUtils.getString(R.string.lab_look_again), { dialog, _ -> dialog.dismiss() - DialogLoader.getInstance().showConfirmDialog( - context, - ResUtils.getString(R.string.title_reminder), - String.format( - ResUtils.getString(R.string.content_privacy_explain_again), - ResUtils.getString(R.string.app_name) - ), - ResUtils.getString(R.string.lab_look_again), - { dialog, _ -> - dialog.dismiss() - showPrivacyDialog(context, submitListener) - }, - ResUtils.getString(R.string.lab_still_disagree) - ) { dialog, _ -> + showPrivacyDialog(context, submitListener) + }, ResUtils.getString(R.string.lab_still_disagree) + ) { dialog, _ -> + dialog.dismiss() + DialogLoader.getInstance().showConfirmDialog( + context, ResUtils.getString(R.string.content_think_about_it_again), ResUtils.getString(R.string.lab_look_again), { dialog, _ -> dialog.dismiss() - DialogLoader.getInstance().showConfirmDialog( - context, - ResUtils.getString(R.string.content_think_about_it_again), - ResUtils.getString(R.string.lab_look_again), - { dialog, _ -> - dialog.dismiss() - showPrivacyDialog(context, submitListener) - }, - ResUtils.getString(R.string.lab_exit_app) - ) { dialog, _ -> - dialog.dismiss() - XUtil.exitApp() - } - } - }.build() + showPrivacyDialog(context, submitListener) + }, ResUtils.getString(R.string.lab_exit_app) + ) { dialog, _ -> + dialog.dismiss() + XUtil.exitApp() + } + } + }.build() dialog.setContent(getPrivacyContent(context)) //开始响应点击事件 dialog.contentView!!.movementMethod = LinkMovementMethod.getInstance() @@ -114,15 +99,7 @@ class CommonUtils private constructor() { * @return 隐私政策说明 */ private fun getPrivacyContent(context: Context): SpannableStringBuilder { - return SpannableStringBuilder() - .append(" ").append(ResUtils.getString(R.string.privacy_content_1)).append(" ").append(ResUtils.getString(R.string.app_name)).append("!\n") - .append(" ").append(ResUtils.getString(R.string.privacy_content_2)) - .append(" ").append(ResUtils.getString(R.string.privacy_content_3)) - .append(getPrivacyLink(context, PRIVACY_URL)) - .append(ResUtils.getString(R.string.privacy_content_4)) - .append(" ").append(ResUtils.getString(R.string.privacy_content_5)) - .append(getPrivacyLink(context, PRIVACY_URL)) - .append(ResUtils.getString(R.string.privacy_content_6)) + return SpannableStringBuilder().append(" ").append(ResUtils.getString(R.string.privacy_content_1)).append(" ").append(ResUtils.getString(R.string.app_name)).append("!\n").append(" ").append(ResUtils.getString(R.string.privacy_content_2)).append(" ").append(ResUtils.getString(R.string.privacy_content_3)).append(getPrivacyLink(context, PRIVACY_URL)).append(ResUtils.getString(R.string.privacy_content_4)).append(" ").append(ResUtils.getString(R.string.privacy_content_5)).append(getPrivacyLink(context, PRIVACY_URL)).append(ResUtils.getString(R.string.privacy_content_6)) } /** @@ -132,8 +109,7 @@ class CommonUtils private constructor() { @Suppress("SameParameterValue") private fun getPrivacyLink(context: Context, privacyUrl: String): SpannableString { val privacyName = String.format( - ResUtils.getString(R.string.lab_privacy_name), - ResUtils.getString(R.string.app_name) + ResUtils.getString(R.string.lab_privacy_name), ResUtils.getString(R.string.app_name) ) val spannableString = SpannableString(privacyName) spannableString.setSpan(object : ClickableSpan() { @@ -165,15 +141,11 @@ class CommonUtils private constructor() { */ @JvmStatic fun gotoProtocol(fragment: XPageFragment?, isPrivacy: Boolean, isImmersive: Boolean) { - PageOption.to(ServiceProtocolFragment::class.java) - .putString( - ServiceProtocolFragment.KEY_PROTOCOL_TITLE, - if (isPrivacy) ResUtils.getString(R.string.title_privacy_protocol) else ResUtils.getString( - R.string.title_user_protocol - ) + PageOption.to(ServiceProtocolFragment::class.java).putString( + ServiceProtocolFragment.KEY_PROTOCOL_TITLE, if (isPrivacy) ResUtils.getString(R.string.title_privacy_protocol) else ResUtils.getString( + R.string.title_user_protocol ) - .putBoolean(ServiceProtocolFragment.KEY_IS_IMMERSIVE, isImmersive) - .open(fragment!!) + ).putBoolean(ServiceProtocolFragment.KEY_IS_IMMERSIVE, isImmersive).open(fragment!!) } /** @@ -210,13 +182,7 @@ class CommonUtils private constructor() { } val bounds = Rect() view?.getGlobalVisibleRect(bounds) - PreviewBuilder.from(fragment) - .setImgs(ImageInfo.newInstance(url, bounds)) - .setCurrentIndex(0) - .setSingleFling(true) - .setProgressColor(R.color.xui_config_color_main_theme) - .setType(PreviewBuilder.IndicatorType.Number) - .start() + PreviewBuilder.from(fragment).setImgs(ImageInfo.newInstance(url, bounds)).setCurrentIndex(0).setSingleFling(true).setProgressColor(R.color.xui_config_color_main_theme).setType(PreviewBuilder.IndicatorType.Number).start() } /** @@ -228,11 +194,7 @@ class CommonUtils private constructor() { */ @JvmStatic fun previewMarkdown(fragment: XPageFragment?, title: String, url: String, isImmersive: Boolean) { - PageOption.to(MarkdownFragment::class.java) - .putString(MarkdownFragment.KEY_MD_TITLE, title) - .putString(MarkdownFragment.KEY_MD_URL, url) - .putBoolean(MarkdownFragment.KEY_IS_IMMERSIVE, isImmersive) - .open(fragment!!) + PageOption.to(MarkdownFragment::class.java).putString(MarkdownFragment.KEY_MD_TITLE, title).putString(MarkdownFragment.KEY_MD_URL, url).putBoolean(MarkdownFragment.KEY_IS_IMMERSIVE, isImmersive).open(fragment!!) } //是否合法的url @@ -243,7 +205,7 @@ class CommonUtils private constructor() { //是否合法的url fun checkUrl(urls: String?, emptyResult: Boolean): Boolean { if (TextUtils.isEmpty(urls)) return emptyResult - val regex = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;\\[\\]]*[-a-zA-Z0-9+&@#/%=~_|\\[\\]]" + val regex = "^(https?|ftp|file)://[-a-zA-Z\\d+&@#/%?=~_|!:,.;\\[\\]]*[-a-zA-Z\\d+&@#/%=~_|\\[\\]]" val pat = Pattern.compile(regex) val mat = pat.matcher(urls?.trim() ?: "") return mat.matches() @@ -257,12 +219,41 @@ class CommonUtils private constructor() { //是否合法的URL Scheme fun checkUrlScheme(urls: String?, emptyResult: Boolean): Boolean { if (TextUtils.isEmpty(urls)) return emptyResult - val regex = "^[a-zA-Z0-9]+://[-a-zA-Z0-9+&@#/%?=~_|!:,.;\\[\\]]*[-a-zA-Z0-9+&@#/%=~_|\\[\\]]" + val regex = "^[a-zA-Z\\d]+://[-a-zA-Z\\d+&@#/%?=~_|!:,.;\\[\\]]*[-a-zA-Z\\d+&@#/%=~_|\\[\\]]" val pat = Pattern.compile(regex) val mat = pat.matcher(urls?.trim() ?: "") return mat.matches() } + //是否合法的IP地址 + fun checkIP(IP: String): String { + if (TextUtils.isEmpty(IP)) return "Neither" + + if (IP.contains(".")) { + val chunkIPv4 = "([\\d]|[1-9][\\d]|1[\\d][\\d]|2[0-4][\\d]|25[0-5])" + val pattenIPv4 = Pattern.compile("^($chunkIPv4\\.){3}$chunkIPv4$") + return if (pattenIPv4.matcher(IP).matches()) "IPv4" else "Neither" + } else if (IP.contains(":")) { + val chunkIPv6 = "([\\da-fA-F]{1,4})" + val pattenIPv6 = Pattern.compile("^($chunkIPv6\\:){7}$chunkIPv6$") + return if (pattenIPv6.matcher(IP).matches()) "IPv6" else "Neither" + } + return "Neither" + } + + //是否合法的域名 + fun checkDomain(domain: String): Boolean { + val pattenDomain = Pattern.compile("^(?=^.{3,255}$)(?:(?:(?:[a-zA-Z\\d]|[a-zA-Z\\d][a-zA-Z\\d\\-]*[a-zA-Z\\d])\\.){1,126}(?:[A-Za-z\\d]|[A-Za-z\\d][A-Za-z\\d\\-]*[A-Za-z\\d]))$") + return pattenDomain.matcher(domain).matches() + } + + //是否合法的端口号 + fun checkPort(port: String): Boolean { + if (TextUtils.isEmpty(port)) return false + val pattenPort = Pattern.compile("^((6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])|[0-5]?\\d{0,4})$") + return pattenPort.matcher(port).matches() + } + //是否启用通知监听服务 fun isNotificationListenerServiceEnabled(context: Context): Boolean { val packageNames = NotificationManagerCompat.getEnabledListenerPackages(context) @@ -273,12 +264,10 @@ class CommonUtils private constructor() { fun toggleNotificationListenerService(context: Context) { val pm = context.packageManager pm.setComponentEnabledSetting( - ComponentName(context.applicationContext, NotifyService::class.java), - PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP + ComponentName(context.applicationContext, NotifyService::class.java), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) pm.setComponentEnabledSetting( - ComponentName(context.applicationContext, NotifyService::class.java), - PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP + ComponentName(context.applicationContext, NotifyService::class.java), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ) } diff --git a/app/src/main/java/com/idormy/sms/forwarder/utils/Constants.kt b/app/src/main/java/com/idormy/sms/forwarder/utils/Constants.kt index 5aa1dcef..649358d1 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/utils/Constants.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/utils/Constants.kt @@ -179,6 +179,7 @@ const val TYPE_GOTIFY = 11 const val TYPE_DINGTALK_INNER_ROBOT = 12 const val TYPE_FEISHU_APP = 13 const val TYPE_URL_SCHEME = 14 +const val TYPE_SOCKET = 15 var SENDER_FRAGMENT_LIST = listOf( PageInfo( getString(R.string.dingtalk_robot), @@ -285,6 +286,13 @@ var SENDER_FRAGMENT_LIST = listOf( CoreAnim.slide, R.drawable.icon_url_scheme ), + PageInfo( + getString(R.string.socket), + "com.idormy.sms.forwarder.fragment.senders.SocketFragment", + "{\"\":\"\"}", + CoreAnim.slide, + R.drawable.icon_socket + ), ) //前台服务 diff --git a/app/src/main/java/com/idormy/sms/forwarder/utils/SendUtils.kt b/app/src/main/java/com/idormy/sms/forwarder/utils/SendUtils.kt index 5e16311c..25ad525e 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/utils/SendUtils.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/utils/SendUtils.kt @@ -54,7 +54,7 @@ object SendUtils { } } - var rule = item.rule + val rule = item.rule rule.senderLogic = SENDER_LOGIC_RETRY sendMsgSender(msgInfo, rule, senderIndex, logId, item.msg.id) } @@ -124,6 +124,10 @@ object SendUtils { val settingVo = Gson().fromJson(sender.jsonSetting, UrlSchemeSetting::class.java) UrlSchemeUtils.sendMsg(settingVo, msgInfo, rule, senderIndex, logId, msgId) } + TYPE_SOCKET -> { + val settingVo = Gson().fromJson(sender.jsonSetting, SocketSetting::class.java) + SocketUtils.sendMsg(settingVo, msgInfo, rule, senderIndex, logId, msgId) + } else -> { updateLogs(logId, 0, "未知发送通道") } diff --git a/app/src/main/java/com/idormy/sms/forwarder/utils/sender/SocketUtils.kt b/app/src/main/java/com/idormy/sms/forwarder/utils/sender/SocketUtils.kt new file mode 100644 index 00000000..26bb5ea0 --- /dev/null +++ b/app/src/main/java/com/idormy/sms/forwarder/utils/sender/SocketUtils.kt @@ -0,0 +1,182 @@ +package com.idormy.sms.forwarder.utils.sender + +import android.annotation.SuppressLint +import android.text.TextUtils +import android.util.Base64 +import com.gitee.xuankaicat.kmnkt.socket.MqttQuality +import com.gitee.xuankaicat.kmnkt.socket.dsl.mqtt +import com.gitee.xuankaicat.kmnkt.socket.dsl.tcp +import com.gitee.xuankaicat.kmnkt.socket.dsl.udp +import com.gitee.xuankaicat.kmnkt.socket.open +import com.gitee.xuankaicat.kmnkt.socket.utils.Charset +import com.google.gson.Gson +import com.idormy.sms.forwarder.database.entity.Rule +import com.idormy.sms.forwarder.entity.MsgInfo +import com.idormy.sms.forwarder.entity.setting.SocketSetting +import com.idormy.sms.forwarder.utils.SendUtils +import com.idormy.sms.forwarder.utils.SettingUtils +import com.xuexiang.xutil.app.AppUtils +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.text.SimpleDateFormat +import java.util.* +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +@Suppress("PrivatePropertyName", "UNUSED_PARAMETER", "unused") +class SocketUtils { + companion object { + + private val TAG: String = SocketUtils::class.java.simpleName + + fun sendMsg( + setting: SocketSetting, msgInfo: MsgInfo, rule: Rule? = null, senderIndex: Int = 0, logId: Long = 0L, msgId: Long = 0L + ) { + val from: String = msgInfo.from + val content: String = if (rule != null) { + msgInfo.getContentForSend(rule.smsTemplate, rule.regexReplace) + } else { + msgInfo.getContentForSend(SettingUtils.smsTemplate) + } + + val timestamp = System.currentTimeMillis() + val orgContent: String = msgInfo.content + val deviceMark: String = SettingUtils.extraDeviceMark + val appVersion: String = AppUtils.getAppVersionName() + val simInfo: String = msgInfo.simInfo + @SuppressLint("SimpleDateFormat") val receiveTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date()) //smsVo.getDate() + var sign = "" + if (!TextUtils.isEmpty(setting.secret)) { + val stringToSign = "$timestamp\n" + setting.secret + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(setting.secret?.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) + val signData = mac.doFinal(stringToSign.toByteArray(StandardCharsets.UTF_8)) + sign = URLEncoder.encode(String(Base64.encode(signData, Base64.NO_WRAP)), "UTF-8") + } + + var message = if (TextUtils.isEmpty(setting.msgTemplate)) "{\"msg\": \"[msg]\"}" else setting.msgTemplate + message = if (message.startsWith("{")) { + message.replace("[from]", from).replace("[content]", escapeJson(content)).replace("[msg]", escapeJson(content)).replace("[org_content]", escapeJson(orgContent)).replace("[device_mark]", escapeJson(deviceMark)).replace("[app_version]", appVersion).replace("[title]", escapeJson(simInfo)).replace("[card_slot]", escapeJson(simInfo)).replace("[receive_time]", receiveTime).replace("[timestamp]", timestamp.toString()).replace("[sign]", sign) + } else { + message.replace("[from]", URLEncoder.encode(from, "UTF-8")).replace("[content]", URLEncoder.encode(content, "UTF-8")).replace("[msg]", URLEncoder.encode(content, "UTF-8")).replace("[org_content]", URLEncoder.encode(orgContent, "UTF-8")).replace("[device_mark]", URLEncoder.encode(deviceMark, "UTF-8")).replace("[app_version]", URLEncoder.encode(appVersion, "UTF-8")).replace("[title]", URLEncoder.encode(simInfo, "UTF-8")).replace("[card_slot]", URLEncoder.encode(simInfo, "UTF-8")).replace("[receive_time]", URLEncoder.encode(receiveTime, "UTF-8")).replace("\n", "%0A").replace("[timestamp]", timestamp.toString()).replace("[sign]", sign) + } + + if (setting.method == "TCP" || setting.method == "UDP") { + var isReceived = false + var isConnected = false + val socket = if (setting.method == "TCP") { + tcp { + address = setting.address//设置ip地址 + port = setting.port//设置端口号 + if (!TextUtils.isEmpty(setting.inCharset)) inCharset = Charset.forName(setting.inCharset)//设置输入编码 + if (!TextUtils.isEmpty(setting.outCharset)) outCharset = Charset.forName(setting.outCharset)//设置输出编码 + } + } else { + udp { + address = setting.address//设置ip地址 + port = setting.port//设置端口号 + if (!TextUtils.isEmpty(setting.inCharset)) inCharset = Charset.forName(setting.inCharset)//设置输入编码 + if (!TextUtils.isEmpty(setting.outCharset)) outCharset = Charset.forName(setting.outCharset)//设置输出编码 + } + } + + socket.open { + success { + //开启连接成功时执行 + isConnected = true + SendUtils.updateLogs(logId, 1, "TCP连接成功") + socket.send(message) + socket.startReceive { str, data -> + isReceived = true + android.util.Log.d(TAG, "str=$str,data=$data") + SendUtils.updateLogs(logId, 2, "收到订阅消息:str=$str,data=$data") + SendUtils.senderLogic(2, msgInfo, rule, senderIndex, msgId) + return@startReceive false + } + } + failure { + //开启连接失败时执行 + val status = 0 + SendUtils.updateLogs(logId, status, "TCP连接失败") + SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId) + return@failure false//是否继续尝试连接 + } + loss { + //失去连接时执行 + return@loss false//是否尝试重连 + } + } + + //延时5秒关闭连接 + if (isConnected) { + Thread.sleep(5000) + socket.stopReceive() + socket.close() + if (!isReceived) { + SendUtils.updateLogs(logId, 0, "未收到订阅消息") + SendUtils.senderLogic(0, msgInfo, rule, senderIndex, msgId) + } + } + return + + } else if (setting.method == "MQTT") { + val mqtt = mqtt { + address = setting.address//设置ip地址 + port = setting.port//设置端口号 + if (!TextUtils.isEmpty(setting.inCharset)) inCharset = Charset.forName(setting.inCharset)//设置输入编码 + if (!TextUtils.isEmpty(setting.outCharset)) outCharset = Charset.forName(setting.outCharset)//设置输出编码 + if (!TextUtils.isEmpty(setting.username)) username = setting.username + if (!TextUtils.isEmpty(setting.password)) password = setting.password + if (!TextUtils.isEmpty(setting.inMessageTopic)) inMessageTopic = setting.inMessageTopic + if (!TextUtils.isEmpty(setting.outMessageTopic)) outMessageTopic = setting.outMessageTopic + //自定义配置 + qos = MqttQuality.ExactlyOnce // 服务质量 详见MqttQuality + if (!TextUtils.isEmpty(setting.uriType)) uriType = setting.uriType //通信方式 默认为tcp + if (!TextUtils.isEmpty(setting.clientId)) clientId = setting.clientId //客户端ID,如果为空则为随机值 + timeOut = 10 //设置超时时间 + cleanSession = true //断开连接后是否清楚缓存,如果清除缓存则在重连后需要手动恢复订阅。 + keepAliveInterval = 20 //检测连接是否中断的间隔 + //行为配置 + threadLock = false //是否启用线程同步锁 默认false + } + + mqtt.open { + success { + //开启连接成功时执行 + SendUtils.updateLogs(logId, 1, "MQTT连接成功") + // 订阅并发布后等待至拿到响应消息并赋值给result + // 如果超过10秒没有收到消息则将result设为"消息响应超时",并取消订阅topic + val response = mqtt.sendAndReceiveSync(setting.outMessageTopic, setting.inMessageTopic, message, 10000L) ?: "消息响应超时" + mqtt.close() + + val status = if (response == "消息响应超时") 0 else 2 + SendUtils.updateLogs(logId, status, "收到订阅消息:$response") + SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId) + return@success + } + failure { + //开启连接失败时执行 + val status = 0 + SendUtils.updateLogs(logId, status, "MQTT连接失败") + SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId) + return@failure false//是否继续尝试连接 + } + loss { + //失去连接时执行 + return@loss false//是否尝试重连 + } + } + + } + + } + + //JSON需要转义的字符 + private fun escapeJson(str: String?): String { + if (str == null) return "null" + val jsonStr: String = Gson().toJson(str) + return if (jsonStr.length >= 2) jsonStr.substring(1, jsonStr.length - 1) else jsonStr + } + + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_socket.webp b/app/src/main/res/drawable/icon_socket.webp new file mode 100644 index 00000000..1f4f0462 Binary files /dev/null and b/app/src/main/res/drawable/icon_socket.webp differ diff --git a/app/src/main/res/layout/adapter_logs_card_view_list_item.xml b/app/src/main/res/layout/adapter_logs_card_view_list_item.xml index bf80a7e2..8f7ac846 100644 --- a/app/src/main/res/layout/adapter_logs_card_view_list_item.xml +++ b/app/src/main/res/layout/adapter_logs_card_view_list_item.xml @@ -16,70 +16,62 @@ + android:orientation="vertical" + tools:ignore="DisableBaselineAlignment"> + android:gravity="center_vertical" + android:orientation="horizontal"> - - - - - - - - - + android:layout_weight="1" /> - - + android:layout_marginStart="5dp" /> - + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/adapter_rules_card_view_list_item.xml b/app/src/main/res/layout/adapter_rules_card_view_list_item.xml index 594d0162..3073d93c 100644 --- a/app/src/main/res/layout/adapter_rules_card_view_list_item.xml +++ b/app/src/main/res/layout/adapter_rules_card_view_list_item.xml @@ -65,7 +65,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="5dp" - android:orientation="horizontal"> + android:orientation="horizontal" /> diff --git a/app/src/main/res/layout/fragment_rules_edit.xml b/app/src/main/res/layout/fragment_rules_edit.xml index 7b89778a..7a2eb7a1 100644 --- a/app/src/main/res/layout/fragment_rules_edit.xml +++ b/app/src/main/res/layout/fragment_rules_edit.xml @@ -59,7 +59,7 @@ android:id="@+id/layout_Senders" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical"> + android:orientation="vertical" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_server.xml b/app/src/main/res/layout/fragment_server.xml index 772bcf43..b9ed264d 100644 --- a/app/src/main/res/layout/fragment_server.xml +++ b/app/src/main/res/layout/fragment_server.xml @@ -664,7 +664,8 @@ + android:layout_height="wrap_content" + tools:ignore="TooManyViews"> + android:visibility="gone" + tools:ignore="RtlSymmetry"> Beagle + + UTF-8 + US-ASCII + ISO-8859-1 + UTF-16 + UTF-16BE + UTF-16LE + UTF-32 + UTF-32LE + UTF-32BE + + \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7a6eee8d..4633e788 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -398,6 +398,9 @@ GET PUT PATCH + UDP + TCP + MQTT Local IP: Instructions: \n[Note] The APP version of the sender and receiver must be the same!\n1. Please keep the SOURCE and DESTINATION phones in the same Wi-Fi network, and do not turn on isolation. \n2. Tap "Send" on SOURCE mobile phone, and get "server IP" \n3. After filling in "Server IP" on DESTINATION phone, tap "Receive". \n [NOTE:] sender(s), forwarding rule(s) and log(s) will be overwritten after cloning! @@ -410,8 +413,12 @@ Old Phone New Phone Server IP: + Host: + Port: . - Please enter a valid IP address + Please enter a valid IP or domain + Please enter a valid message topic + Please enter a valid port The server is started successfully The server has been stopped This mobile phone is the SOURCE and cannot receive files. @@ -467,6 +474,18 @@ Proxy Authenticator Username Password + In Charset + Out Charset + In Message Topic + Receive messages on the corresponding topic + Out Message Topic + Send a message on the corresponding topic + Uri Type + Optional, default is tcp + Path + Used to set the uri when communicating using ws + Client Id + Random value if empty Privacy Policy Agree Refuse @@ -706,10 +725,18 @@ URL Scheme Example:myapp://api/add?&type=0&msg=[msg] + Socket + Example:myapp://api/add?&type=0&msg=[msg] + 服务地址 + IP 或 域名 + 端口 + 取值范围:1-65535 + Webhook Server For example: https://a.b.com/msg?token=xyz Params - For example: payload=%7B%22text%22%3A%22[msg]%22%7D [msg] will be replaced with SMS content.\nJson format is supported, for example: {\"text\":\"[msg]\"}.\nNote: msg is automatically UTF-8 encoded except in JSON format + Msg Template + For example: payload=%7B%22text%22%3A%22[msg]%22%7D [msg] will be replaced with SMS content.\nJson format is supported, for example: {\"text\":\"[msg]\"}.\nNote: msg is automatically URLEncoder except in JSON format Secret: If it is empty, the sign will not be calculated Headers Key diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 64a0cf0d..0d598342 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -143,4 +143,16 @@ Beagle + + UTF-8 + US-ASCII + ISO-8859-1 + UTF-16 + UTF-16BE + UTF-16LE + UTF-32 + UTF-32LE + UTF-32BE + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3dd75dd7..c823ff35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -399,6 +399,9 @@ GET PUT PATCH + UDP + TCP + MQTT 本机IP: 严正声明:\n该功能仅限个人新旧手机切换使用,用于非法用途后果自负!\n\n操作说明:\n1.新旧手机连接同一个WiFi网络(禁用AP隔离),如需穿透内网请先配置Frpc\n2.【二选一】旧手机点【推送】按钮,将本机的配置推送到服务端\n3.【二选一】新手机点【拉取】按钮,将拉取服务端的配置到本机\n\n注意事项:\n1.客户端与服务端的APP版本必须一致,才能克隆!\n2.导入成功后,发送通道、转发规则将完全被覆盖,清空历史记录!\n3.主动请求、保活措施、个性设置不在克隆范围 @@ -411,8 +414,12 @@ 我是旧手机 我是新手机 服务端IP: + 主机: + 端口: . - 请输入合法的IP地址 + 请输入合法的IP或域名 + 请输入合法的输入/输出信息主题 + 请输入合法的端口号 服务端已启动 服务端已停止 本手机是发送端,不可接收文件,请先停止服务端! @@ -468,6 +475,18 @@ 代理身份验证 用户 密码 + 输入编码 + 输出编码 + 输入消息主题 + 接收对应主题的消息 + 输出消息主题 + 发送对应主题的消息 + 通信方式 + 可选,默认为tcp + 通信路径 + 用于在使用ws进行通信时设置uri + 客户端ID + 如果为空则为随机值 隐私政策 同意 拒绝 @@ -707,10 +726,18 @@ URL Scheme 示例:myapp://api/add?&type=0&msg=[msg] + Socket + 示例:myapp://api/add?&type=0&msg=[msg] + 服务地址 + IP 或 域名 + 端口 + 取值范围:1-65535 + Webhook Server 例如:https://a.b.com/msg?token=xyz - Params - 例如:payload=%7B%22text%22%3A%22[msg]%22%7D [msg]将被替换成短信内容。\n支持Json格式,例如:{\"text\":\"[msg]\"}。\n注意:除JSON格式外,msg会自动进行UTF-8编码 + 消息模板 + Params + 例如:payload=%7B%22text%22%3A%22[msg]%22%7D [msg]将被替换成短信内容。\n支持Json格式,例如:{\"text\":\"[msg]\"}。\n注意:除JSON格式外,msg会自动进行URLEncoder Secret:置空则不计算sign Headers Key