新增:支持Bark推送加密 #273 (详见:https://bark.day.app/#/encryption)

This commit is contained in:
pppscn 2023-05-13 23:32:04 +08:00
parent f5de522967
commit 1bc2668ab2
7 changed files with 216 additions and 77 deletions

View File

@ -19,4 +19,10 @@ data class BarkSetting(
val level: String? = "active", val level: String? = "active",
//标题模板 //标题模板
val title: String? = "", val title: String? = "",
//加密算法
val transformation: String = "none",
//加密密钥
val key: String = "",
//初始偏移向量
val iv: String = "",
) : Serializable ) : Serializable

View File

@ -46,6 +46,7 @@ class BarkFragment : BaseFragment<FragmentSendersBarkBinding?>(), View.OnClickLi
private val viewModel by viewModels<SenderViewModel> { BaseViewModelFactory(context) } private val viewModel by viewModels<SenderViewModel> { BaseViewModelFactory(context) }
private var mCountDownHelper: CountDownButtonHelper? = null private var mCountDownHelper: CountDownButtonHelper? = null
private var barkLevel: String = "active" //通知级别 private var barkLevel: String = "active" //通知级别
private var transformation: String = "none" //加密算法
@JvmField @JvmField
@AutoWired(name = KEY_SENDER_ID) @AutoWired(name = KEY_SENDER_ID)
@ -103,6 +104,18 @@ class BarkFragment : BaseFragment<FragmentSendersBarkBinding?>(), View.OnClickLi
} }
binding!!.spLevel.selectedIndex = 0 binding!!.spLevel.selectedIndex = 0
binding!!.spEncryptionAlgorithm.setItems(BARK_ENCRYPTION_ALGORITHM_MAP.values.toList())
binding!!.spEncryptionAlgorithm.setOnItemSelectedListener { _: MaterialSpinner?, _: Int, _: Long, item: Any ->
BARK_ENCRYPTION_ALGORITHM_MAP.forEach {
if (it.value == item) transformation = it.key
}
}
binding!!.spEncryptionAlgorithm.setOnNothingSelectedListener {
binding!!.spEncryptionAlgorithm.selectedIndex = 0
transformation = "none"
}
binding!!.spEncryptionAlgorithm.selectedIndex = 0
//新增 //新增
if (senderId <= 0) { if (senderId <= 0) {
titleBar?.setSubTitle(getString(R.string.add_sender)) titleBar?.setSubTitle(getString(R.string.add_sender))
@ -112,43 +125,43 @@ class BarkFragment : BaseFragment<FragmentSendersBarkBinding?>(), View.OnClickLi
//编辑 //编辑
binding!!.btnDel.setText(R.string.del) binding!!.btnDel.setText(R.string.del)
AppDatabase.getInstance(requireContext()) AppDatabase.getInstance(requireContext()).senderDao().get(senderId).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(object : SingleObserver<Sender> {
.senderDao() override fun onSubscribe(d: Disposable) {}
.get(senderId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : SingleObserver<Sender> {
override fun onSubscribe(d: Disposable) {}
override fun onError(e: Throwable) { override fun onError(e: Throwable) {
e.printStackTrace() e.printStackTrace()
} }
override fun onSuccess(sender: Sender) { override fun onSuccess(sender: Sender) {
if (isClone) { if (isClone) {
titleBar?.setSubTitle(getString(R.string.clone_sender) + ": " + sender.name) titleBar?.setSubTitle(getString(R.string.clone_sender) + ": " + sender.name)
binding!!.btnDel.setText(R.string.discard) binding!!.btnDel.setText(R.string.discard)
} else { } else {
titleBar?.setSubTitle(getString(R.string.edit_sender) + ": " + sender.name) 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, BarkSetting::class.java)
Log.d(TAG, settingVo.toString())
if (settingVo != null) {
binding!!.etServer.setText(settingVo.server)
binding!!.etGroup.setText(settingVo.group)
binding!!.etIcon.setText(settingVo.icon)
binding!!.etSound.setText(settingVo.sound)
binding!!.etBadge.setText(settingVo.badge)
binding!!.etUrl.setText(settingVo.url)
BARK_LEVEL_MAP.forEach {
if (it.key == settingVo.level) binding!!.spLevel.setSelectedItem(it.value)
}
binding!!.etTitleTemplate.setText(settingVo.title)
}
} }
}) binding!!.etName.setText(sender.name)
binding!!.sbEnable.isChecked = sender.status == 1
val settingVo = Gson().fromJson(sender.jsonSetting, BarkSetting::class.java)
Log.d(TAG, settingVo.toString())
if (settingVo != null) {
binding!!.etServer.setText(settingVo.server)
binding!!.etGroup.setText(settingVo.group)
binding!!.etIcon.setText(settingVo.icon)
binding!!.etSound.setText(settingVo.sound)
binding!!.etBadge.setText(settingVo.badge)
binding!!.etUrl.setText(settingVo.url)
BARK_LEVEL_MAP.forEach {
if (it.key == settingVo.level) binding!!.spLevel.setSelectedItem(it.value)
}
binding!!.etTitleTemplate.setText(settingVo.title)
BARK_ENCRYPTION_ALGORITHM_MAP.forEach {
if (it.value == settingVo.transformation) binding!!.spEncryptionAlgorithm.setSelectedItem(it.value)
}
binding!!.etEncryptionKey.setText(settingVo.key)
binding!!.etEncryptionIv.setText(settingVo.iv)
}
}
})
} }
@ -172,18 +185,22 @@ class BarkFragment : BaseFragment<FragmentSendersBarkBinding?>(), View.OnClickLi
CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_from)) CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_from))
return return
} }
R.id.bt_insert_extra -> { R.id.bt_insert_extra -> {
CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_card_slot)) CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_card_slot))
return return
} }
R.id.bt_insert_time -> { R.id.bt_insert_time -> {
CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_receive_time)) CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_receive_time))
return return
} }
R.id.bt_insert_device_name -> { R.id.bt_insert_device_name -> {
CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_device_name)) CommonUtils.insertOrReplaceText2Cursor(etTitleTemplate, getString(R.string.tag_device_name))
return return
} }
R.id.btn_test -> { R.id.btn_test -> {
mCountDownHelper?.start() mCountDownHelper?.start()
Thread { Thread {
@ -202,25 +219,21 @@ class BarkFragment : BaseFragment<FragmentSendersBarkBinding?>(), View.OnClickLi
}.start() }.start()
return return
} }
R.id.btn_del -> { R.id.btn_del -> {
if (senderId <= 0 || isClone) { if (senderId <= 0 || isClone) {
popToBack() popToBack()
return return
} }
MaterialDialog.Builder(requireContext()) 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? ->
.title(R.string.delete_sender_title) viewModel.delete(senderId)
.content(R.string.delete_sender_tips) XToastUtils.success(R.string.delete_sender_toast)
.positiveText(R.string.lab_yes) popToBack()
.negativeText(R.string.lab_no) }.show()
.onPositive { _: MaterialDialog?, _: DialogAction? ->
viewModel.delete(senderId)
XToastUtils.success(R.string.delete_sender_toast)
popToBack()
}
.show()
return return
} }
R.id.btn_save -> { R.id.btn_save -> {
val name = binding!!.etName.text.toString().trim() val name = binding!!.etName.text.toString().trim()
if (TextUtils.isEmpty(name)) { if (TextUtils.isEmpty(name)) {
@ -262,8 +275,17 @@ class BarkFragment : BaseFragment<FragmentSendersBarkBinding?>(), View.OnClickLi
throw Exception(getString(R.string.invalid_bark_url)) throw Exception(getString(R.string.invalid_bark_url))
} }
val title = binding!!.etTitleTemplate.text.toString().trim() val title = binding!!.etTitleTemplate.text.toString().trim()
val key = binding!!.etEncryptionKey.text.toString().trim()
val iv = binding!!.etEncryptionIv.text.toString().trim()
if (transformation.startsWith("AES128") && (key.length != 16 || iv.length != 16)) {
throw Exception(getString(R.string.bark_encryption_key_error1))
} else if (transformation.startsWith("AES192") && (key.length != 24 || iv.length != 24)) {
throw Exception(getString(R.string.bark_encryption_key_error2))
} else if (transformation.startsWith("AES256") && (key.length != 32 || iv.length != 32)) {
throw Exception(getString(R.string.bark_encryption_key_error3))
}
return BarkSetting(server, group, icon, sound, badge, url, barkLevel, title) return BarkSetting(server, group, icon, sound, badge, url, barkLevel, title, transformation, key, iv)
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@ -167,6 +167,15 @@ val BARK_LEVEL_MAP = mapOf(
"timeSensitive" to getString(R.string.bark_level_timeSensitive), "timeSensitive" to getString(R.string.bark_level_timeSensitive),
"passive" to getString(R.string.bark_level_passive) "passive" to getString(R.string.bark_level_passive)
) )
val BARK_ENCRYPTION_ALGORITHM_MAP = mapOf(
"none" to getString(R.string.bark_encryption_algorithm_none),
"AES128/CBC/PKCS7Padding" to "AES128/CBC/PKCS7Padding",
"AES128/ECB/PKCS7Padding" to "AES128/ECB/PKCS7Padding",
"AES192/CBC/PKCS7Padding" to "AES192/CBC/PKCS7Padding",
"AES192/ECB/PKCS7Padding" to "AES192/ECB/PKCS7Padding",
"AES256/CBC/PKCS7Padding" to "AES256/CBC/PKCS7Padding",
"AES256/ECB/PKCS7Padding" to "AES256/ECB/PKCS7Padding",
)
//发送通道 //发送通道
const val TYPE_DINGTALK_GROUP_ROBOT = 0 const val TYPE_DINGTALK_GROUP_ROBOT = 0

View File

@ -1,6 +1,7 @@
package com.idormy.sms.forwarder.utils.sender package com.idormy.sms.forwarder.utils.sender
import android.text.TextUtils import android.text.TextUtils
import android.util.Base64
import android.util.Log import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.idormy.sms.forwarder.database.entity.Rule import com.idormy.sms.forwarder.database.entity.Rule
@ -13,8 +14,13 @@ import com.xuexiang.xhttp2.XHttp
import com.xuexiang.xhttp2.cache.model.CacheMode import com.xuexiang.xhttp2.cache.model.CacheMode
import com.xuexiang.xhttp2.callback.SimpleCallBack import com.xuexiang.xhttp2.callback.SimpleCallBack
import com.xuexiang.xhttp2.exception.ApiException import com.xuexiang.xhttp2.exception.ApiException
import java.net.URLEncoder
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@Suppress("unused")
class BarkUtils { class BarkUtils {
companion object { companion object {
@ -52,29 +58,40 @@ class BarkUtils {
XHttp.post(requestUrl) XHttp.post(requestUrl)
} }
request.params("title", title) val msgMap: MutableMap<String, Any> = mutableMapOf()
.params("body", content) msgMap["title"] = title
.params("isArchive", 1) msgMap["body"] = content
if (!TextUtils.isEmpty(setting.group)) request.params("group", setting.group) msgMap["isArchive"] = 1
if (!TextUtils.isEmpty(setting.icon)) request.params("icon", setting.icon) if (!TextUtils.isEmpty(setting.group)) msgMap["group"] = setting.group.toString()
if (!TextUtils.isEmpty(setting.level)) request.params("level", setting.level) if (!TextUtils.isEmpty(setting.icon)) msgMap["icon"] = setting.icon.toString()
if (!TextUtils.isEmpty(setting.sound)) request.params("sound", setting.sound) if (!TextUtils.isEmpty(setting.level)) msgMap["level"] = setting.level.toString()
if (!TextUtils.isEmpty(setting.badge)) request.params("badge", setting.badge) if (!TextUtils.isEmpty(setting.sound)) msgMap["sound"] = setting.sound.toString()
if (!TextUtils.isEmpty(setting.url)) request.params("url", setting.url) if (!TextUtils.isEmpty(setting.badge)) msgMap["badge"] = setting.badge.toString()
if (!TextUtils.isEmpty(setting.url)) msgMap["url"] = setting.url.toString()
val isCode: Int = content.indexOf("验证码") //自动复制验证码
val isPassword: Int = content.indexOf("动态密码") val pattern = Regex("(?<!回复)(验证码|授权码|校验码|检验码|确认码|激活码|动态码|安全码|(验证)?代码|校验代码|检验代码|激活代码|确认代码|动态代码|安全代码|登入码|认证码|识别码|短信口令|动态密码|交易码|上网密码|动态口令|随机码|驗證碼|授權碼|校驗碼|檢驗碼|確認碼|激活碼|動態碼|(驗證)?代碼|校驗代碼|檢驗代碼|確認代碼|激活代碼|動態代碼|登入碼|認證碼|識別碼|一次性密码|[Cc][Oo][Dd][Ee]|[Vv]erification)")
val isPassword2: Int = content.indexOf("短信密码") if (pattern.containsMatchIn(content)) {
if (isCode != -1 || isPassword != -1 || isPassword2 != -1) { var code = content.replace("(.*)((代|授权|验证|动态|校验)码|[【\\[].*[】\\]]|[Cc][Oo][Dd][Ee]|[Vv]erification\\s?([Cc]ode)?)\\s?(G-|<#>)?([:\\s是为]|[Ii][Ss]){0,3}[\\(\\[【{「]?(([0-9\\s]{4,7})|([\\dA-Za-z]{5,6})(?!([Vv]erification)?([Cc][Oo][Dd][Ee])|:))[」}】\\]\\)]?(?=([^0-9a-zA-Z]|\$))(.*)".toRegex(), "$7").trim()
val p = Pattern.compile("(\\d{4,6})") code = code.replace("[^\\d]*[\\(\\[【{「]?([0-9]{3}\\s?[0-9]{1,3})[」}】\\]\\)]?(?=.*((代|授权|验证|动态|校验)码|[【\\[].*[】\\]]|[Cc][Oo][Dd][Ee]|[Vv]erification\\s?([Cc]ode)?))(.*)".toRegex(), "$1").trim()
val m = p.matcher(content) if (code.isNotEmpty()) {
if (m.find()) { msgMap["copy"] = code
println(m.group()) msgMap["automaticallyCopy"] = 1
request.params("automaticallyCopy", "1")
request.params("copy", m.group())
} }
} }
val requestMsg: String = Gson().toJson(msgMap)
Log.i(TAG, "requestMsg:$requestMsg")
//推送加密
if (setting.transformation.isNotEmpty() && "none" != setting.transformation && setting.key.isNotEmpty() && setting.iv.isNotEmpty()) {
var transformation = setting.transformation.replace("AES128", "AES").replace("AES192", "AES").replace("AES256", "AES")
val ciphertext = encrypt(requestMsg, transformation, setting.key, setting.iv)
request.params("ciphertext", URLEncoder.encode(ciphertext, "UTF-8"))
request.params("iv", URLEncoder.encode(setting.iv, "UTF-8"))
} else {
request.upJson(requestMsg)
}
request.ignoreHttpsCert() //忽略https证书 request.ignoreHttpsCert() //忽略https证书
.keepJson(true) .keepJson(true)
.timeOut((SettingUtils.requestTimeout * 1000).toLong()) //超时时间10s .timeOut((SettingUtils.requestTimeout * 1000).toLong()) //超时时间10s
@ -106,5 +123,14 @@ class BarkUtils {
} }
fun encrypt(plainText: String, transformation: String, key: String, iv: String): String {
val cipher = Cipher.getInstance(transformation)
val keySpec = SecretKeySpec(key.toByteArray(), "AES")
val ivSpec = IvParameterSpec(iv.toByteArray())
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
return Base64.encode(encryptedBytes, Base64.NO_WRAP).toString()
}
} }
} }

View File

@ -303,6 +303,74 @@
</LinearLayout> </LinearLayout>
<LinearLayout
style="@style/senderBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:text="@string/bark_encryption_algorithm"
android:textStyle="bold" />
<com.xuexiang.xui.widget.spinner.materialspinner.MaterialSpinner
android:id="@+id/sp_encryption_algorithm"
style="@style/Material.SpinnerStyle"
android:layout_marginStart="5dp" />
</LinearLayout>
<LinearLayout
style="@style/senderBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/bark_encryption_key"
android:textStyle="bold" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_encryption_key"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:hint="@string/bark_encryption_key_tips"
android:singleLine="true"
app:met_clearButton="true" />
</LinearLayout>
<LinearLayout
style="@style/senderBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/bark_encryption_iv"
android:textStyle="bold" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_encryption_iv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:hint="@string/bark_encryption_iv_tips"
android:singleLine="true"
app:met_clearButton="true" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>

View File

@ -728,12 +728,16 @@
<string name="bark_url_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string> <string name="bark_url_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string>
<string name="bark_url_regex2" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^[a-z]+://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string> <string name="bark_url_regex2" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^[a-z]+://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string>
<string name="bark_url_error">Url format error</string> <string name="bark_url_error">Url format error</string>
<string name="bark_key">AES Key</string> <string name="bark_encryption_algorithm">Encryption Algorithm</string>
<string name="bark_key_tips">Fill in 16 chars to enable push encryption</string> <string name="bark_encryption_algorithm_none">NONE</string>
<string name="bark_iv">AES iv</string> <string name="bark_encryption_key">AES Key</string>
<string name="bark_iv_tips">Fill in 16 chars to enable push encryption</string> <string name="bark_encryption_key_tips">corresponding key on bark</string>
<string name="bark_key_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^[a-zA-Z0-9]{16}]]></string> <string name="bark_encryption_iv">AES iv</string>
<string name="bark_key_error">AES Key and iv must be 16 characters</string> <string name="bark_encryption_iv_tips">corresponding iv on bark</string>
<string name="bark_encryption_key_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^[a-zA-Z0-9]{16}]]></string>
<string name="bark_encryption_key_error1">AES Key and iv must be 16 characters</string>
<string name="bark_encryption_key_error2">AES Key and iv must be 24 characters</string>
<string name="bark_encryption_key_error3">AES Key and iv must be 32 characters</string>
<string name="from_email_hint">Fill in the username before @</string> <string name="from_email_hint">Fill in the username before @</string>
<string name="from_email_full_hint">Fill in the format: AAA@BBB.CCC</string> <string name="from_email_full_hint">Fill in the format: AAA@BBB.CCC</string>

View File

@ -729,12 +729,16 @@
<string name="bark_url_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string> <string name="bark_url_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string>
<string name="bark_url_regex2" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^[a-z]+://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string> <string name="bark_url_regex2" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^[a-z]+://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]]]></string>
<string name="bark_url_error">Url格式错误</string> <string name="bark_url_error">Url格式错误</string>
<string name="bark_key">加密密钥</string> <string name="bark_encryption_algorithm">加密算法</string>
<string name="bark_key_tips">填写16个字符以启用推送加密</string> <string name="bark_encryption_algorithm_none">不加密</string>
<string name="bark_iv">偏移向量</string> <string name="bark_encryption_key">加密密钥</string>
<string name="bark_iv_tips">填写16个字符以启用推送加密</string> <string name="bark_encryption_key_tips">对应bark上的key</string>
<string name="bark_key_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^([a-zA-Z0-9]{16})? $]]></string> <string name="bark_encryption_iv">偏移向量</string>
<string name="bark_key_error">加密密钥和偏移向量必须同时是16个字符</string> <string name="bark_encryption_iv_tips">对应bark上的iv</string>
<string name="bark_encryption_key_regex" formatted="false" tools:ignore="TypographyDashes"><![CDATA[^([a-zA-Z0-9]{16})? $]]></string>
<string name="bark_encryption_key_error1">加密密钥和偏移向量都必须是16位</string>
<string name="bark_encryption_key_error2">加密密钥和偏移向量都必须是24位</string>
<string name="bark_encryption_key_error3">加密密钥和偏移向量都必须是32位</string>
<string name="from_email_hint">填写 @ 前面的用户名</string> <string name="from_email_hint">填写 @ 前面的用户名</string>
<string name="from_email_full_hint">填写格式: AAA@BBB.CCC</string> <string name="from_email_full_hint">填写格式: AAA@BBB.CCC</string>