新增:发送通道`电子邮箱`支持`S/MIME`或`OpenPGP`加密 #417
parent
8cefd5fded
commit
75b356246c
@ -1,28 +0,0 @@
|
|||||||
package com.idormy.sms.forwarder.utils.mail
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
|
||||||
* desc: 邮件实体类
|
|
||||||
* time: 2019/8/1
|
|
||||||
* @author teprinciple
|
|
||||||
*/
|
|
||||||
data class Mail(
|
|
||||||
var mailServerHost: String = "", // 发件箱邮箱服务器地址
|
|
||||||
var mailServerPort: String = "", // 发件箱邮箱服务器端口
|
|
||||||
var fromAddress: String = "", // 发件箱
|
|
||||||
var fromNickname: String = "", // 发件人昵称
|
|
||||||
var password: String = "", // 发件箱授权码(密码)
|
|
||||||
|
|
||||||
var toAddress: List<String> = ArrayList(), // 直接收件人邮箱
|
|
||||||
var ccAddress: ArrayList<String> = ArrayList(), // 抄送者邮箱
|
|
||||||
var bccAddress: ArrayList<String> = ArrayList(), // 密送者邮箱
|
|
||||||
|
|
||||||
var subject: String = "", // 邮件主题
|
|
||||||
var content: CharSequence = "", // 邮件内容
|
|
||||||
var attachFiles: ArrayList<File> = ArrayList(), // 附件
|
|
||||||
|
|
||||||
var openSSL: Boolean = false, //是否开启ssl验证 默认关闭
|
|
||||||
var sslFactory: String = "javax.net.ssl.SSLSocketFactory", //SSL构建类名
|
|
||||||
var startTls: Boolean = false, //是否开启starttls加密方式 默认关闭
|
|
||||||
)
|
|
@ -1,49 +0,0 @@
|
|||||||
package com.idormy.sms.forwarder.utils.mail
|
|
||||||
|
|
||||||
import com.idormy.sms.forwarder.utils.Log
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.mail.Transport
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 邮件发送器
|
|
||||||
*/
|
|
||||||
object MailSender {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单例
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun getInstance() = this
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送邮件
|
|
||||||
*/
|
|
||||||
fun sendMail(mail: Mail, onMailSendListener: OnMailSendListener? = null) {
|
|
||||||
@Suppress("OPT_IN_USAGE")
|
|
||||||
val send = GlobalScope.async(Dispatchers.IO) {
|
|
||||||
Transport.send(MailUtil.createMailMessage(mail))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("OPT_IN_USAGE")
|
|
||||||
GlobalScope.launch(Dispatchers.Main) {
|
|
||||||
runCatching {
|
|
||||||
send.await()
|
|
||||||
onMailSendListener?.onSuccess()
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("MailSender", it.message.toString())
|
|
||||||
onMailSendListener?.onError(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送回调
|
|
||||||
*/
|
|
||||||
interface OnMailSendListener {
|
|
||||||
fun onSuccess()
|
|
||||||
fun onError(e: Throwable)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,136 +0,0 @@
|
|||||||
package com.idormy.sms.forwarder.utils.mail
|
|
||||||
|
|
||||||
import android.text.Html
|
|
||||||
import android.text.Spanned
|
|
||||||
import com.idormy.sms.forwarder.utils.Log
|
|
||||||
import com.xuexiang.xrouter.utils.TextUtils
|
|
||||||
import java.io.UnsupportedEncodingException
|
|
||||||
import java.util.Properties
|
|
||||||
import javax.activation.DataHandler
|
|
||||||
import javax.activation.FileDataSource
|
|
||||||
import javax.mail.Authenticator
|
|
||||||
import javax.mail.Message
|
|
||||||
import javax.mail.PasswordAuthentication
|
|
||||||
import javax.mail.Session
|
|
||||||
import javax.mail.internet.InternetAddress
|
|
||||||
import javax.mail.internet.MimeBodyPart
|
|
||||||
import javax.mail.internet.MimeMessage
|
|
||||||
import javax.mail.internet.MimeMultipart
|
|
||||||
import javax.mail.internet.MimeUtility
|
|
||||||
|
|
||||||
/**
|
|
||||||
* desc: 邮件帮助类
|
|
||||||
* time: 2019/8/1
|
|
||||||
* @author teprinciple
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
object MailUtil {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建邮件
|
|
||||||
*/
|
|
||||||
fun createMailMessage(mail: Mail): MimeMessage {
|
|
||||||
Log.e("createMailMessage", mail.toString())
|
|
||||||
val properties = Properties()
|
|
||||||
properties["mail.debug"] = "true"
|
|
||||||
properties["mail.smtp.host"] = mail.mailServerHost
|
|
||||||
properties["mail.smtp.port"] = mail.mailServerPort
|
|
||||||
properties["mail.smtp.auth"] = "true"
|
|
||||||
properties["mail.smtp.ssl.enable"] = mail.openSSL
|
|
||||||
if (mail.startTls) {
|
|
||||||
properties["mail.smtp.starttls.enable"] = true
|
|
||||||
}
|
|
||||||
if (mail.openSSL) {
|
|
||||||
properties["mail.smtp.socketFactory.class"] = mail.sslFactory
|
|
||||||
}
|
|
||||||
val authenticator = MailAuthenticator(mail.fromAddress, mail.password)
|
|
||||||
val session = Session.getInstance(properties, authenticator)
|
|
||||||
session.debug = true
|
|
||||||
|
|
||||||
Log.e("createMailMessage", session.toString())
|
|
||||||
return MimeMessage(session).apply {
|
|
||||||
|
|
||||||
// 设置发件箱
|
|
||||||
if (TextUtils.isEmpty(mail.fromNickname)) {
|
|
||||||
setFrom(InternetAddress(mail.fromAddress))
|
|
||||||
} else {
|
|
||||||
var nickname = mail.fromNickname.replace(":", "-").replace("\n", "-")
|
|
||||||
try {
|
|
||||||
Log.d("createMailMessage", "nickname = $nickname")
|
|
||||||
nickname = MimeUtility.encodeText(nickname)
|
|
||||||
} catch (e: UnsupportedEncodingException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
Log.e("createMailMessage", "UnsupportedEncodingException = ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d("createMailMessage", "nickname = $nickname")
|
|
||||||
setFrom(InternetAddress("$nickname <${mail.fromAddress}>"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置直接接收者收件箱
|
|
||||||
val toAddress = mail.toAddress.map {
|
|
||||||
InternetAddress(it)
|
|
||||||
}.toTypedArray()
|
|
||||||
setRecipients(Message.RecipientType.TO, toAddress)
|
|
||||||
|
|
||||||
// 设置抄送者收件箱
|
|
||||||
val ccAddress = mail.ccAddress.map {
|
|
||||||
InternetAddress(it)
|
|
||||||
}.toTypedArray()
|
|
||||||
setRecipients(Message.RecipientType.CC, ccAddress)
|
|
||||||
|
|
||||||
// 设置密送者收件箱
|
|
||||||
val bccAddress = mail.bccAddress.map {
|
|
||||||
InternetAddress(it)
|
|
||||||
}.toTypedArray()
|
|
||||||
setRecipients(Message.RecipientType.BCC, bccAddress)
|
|
||||||
|
|
||||||
// 邮件主题
|
|
||||||
subject = mail.subject.replace(":", "-").replace("\n", "-")
|
|
||||||
try {
|
|
||||||
subject = MimeUtility.encodeText(subject)
|
|
||||||
} catch (e: UnsupportedEncodingException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
Log.e("createMailMessage", "UnsupportedEncodingException = ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 邮件内容
|
|
||||||
val contentPart = MimeMultipart()
|
|
||||||
|
|
||||||
// 邮件正文
|
|
||||||
val textBodyPart = MimeBodyPart()
|
|
||||||
if (mail.content is Spanned) {
|
|
||||||
textBodyPart.setContent(
|
|
||||||
Html.toHtml(mail.content as Spanned),
|
|
||||||
"text/html;charset=UTF-8"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
textBodyPart.setContent(mail.content, "text/html;charset=UTF-8")
|
|
||||||
}
|
|
||||||
contentPart.addBodyPart(textBodyPart)
|
|
||||||
|
|
||||||
// 邮件附件
|
|
||||||
mail.attachFiles.forEach {
|
|
||||||
val fileBodyPart = MimeBodyPart()
|
|
||||||
val ds = FileDataSource(it)
|
|
||||||
val dh = DataHandler(ds)
|
|
||||||
fileBodyPart.dataHandler = dh
|
|
||||||
fileBodyPart.fileName = MimeUtility.encodeText(dh.name)
|
|
||||||
contentPart.addBodyPart(fileBodyPart)
|
|
||||||
}
|
|
||||||
contentPart.setSubType("mixed")
|
|
||||||
setContent(contentPart)
|
|
||||||
saveChanges()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发件箱auth校验
|
|
||||||
*/
|
|
||||||
class MailAuthenticator(username: String?, private var password: String?) : Authenticator() {
|
|
||||||
private var userName: String? = username
|
|
||||||
override fun getPasswordAuthentication(): PasswordAuthentication {
|
|
||||||
return PasswordAuthentication(userName, password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,295 @@
|
|||||||
|
package com.idormy.sms.forwarder.utils.mail
|
||||||
|
|
||||||
|
import com.idormy.sms.forwarder.utils.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import org.bouncycastle.openpgp.PGPPublicKeyRing
|
||||||
|
import org.bouncycastle.openpgp.PGPSecretKeyRing
|
||||||
|
import org.bouncycastle.util.io.Streams
|
||||||
|
import org.pgpainless.PGPainless
|
||||||
|
import org.pgpainless.algorithm.DocumentSignatureType
|
||||||
|
import org.pgpainless.algorithm.HashAlgorithm
|
||||||
|
import org.pgpainless.encryption_signing.EncryptionOptions
|
||||||
|
import org.pgpainless.encryption_signing.ProducerOptions
|
||||||
|
import org.pgpainless.encryption_signing.SigningOptions
|
||||||
|
import org.pgpainless.key.protection.SecretKeyRingProtector
|
||||||
|
import org.pgpainless.util.Passphrase
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.security.Security
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Properties
|
||||||
|
import javax.activation.DataHandler
|
||||||
|
import javax.activation.FileDataSource
|
||||||
|
import javax.mail.Authenticator
|
||||||
|
import javax.mail.Message
|
||||||
|
import javax.mail.Session
|
||||||
|
import javax.mail.Transport
|
||||||
|
import javax.mail.internet.InternetAddress
|
||||||
|
import javax.mail.internet.MimeBodyPart
|
||||||
|
import javax.mail.internet.MimeMessage
|
||||||
|
import javax.mail.internet.MimeMultipart
|
||||||
|
import javax.mail.internet.MimeUtility
|
||||||
|
import javax.mail.util.ByteArrayDataSource
|
||||||
|
|
||||||
|
|
||||||
|
@Suppress("PrivatePropertyName", "unused")
|
||||||
|
class PgpUtils(
|
||||||
|
private val properties: Properties,
|
||||||
|
private val authenticator: Authenticator,
|
||||||
|
// 邮件参数
|
||||||
|
private val from: String, // 发件人邮箱
|
||||||
|
private val nickname: String, // 发件人昵称
|
||||||
|
private val subject: String, // 邮件主题
|
||||||
|
private val body: String, // 邮件正文
|
||||||
|
private val attachFiles: MutableList<File> = mutableListOf(), // 附件
|
||||||
|
// 收件人参数
|
||||||
|
private val toAddress: MutableList<String> = mutableListOf(), // 收件人邮箱
|
||||||
|
private val ccAddress: MutableList<String> = mutableListOf(), // 抄送者邮箱
|
||||||
|
private val bccAddress: MutableList<String> = mutableListOf(), // 密送者邮箱
|
||||||
|
//邮件 PGP 加密和签名
|
||||||
|
private var recipientPGPPublicKeyRing: PGPPublicKeyRing? = null, // 收件人公钥(用于加密)
|
||||||
|
private var senderPGPSecretKeyRing: PGPSecretKeyRing? = null, // 发件人私钥(用于签名)
|
||||||
|
private val senderPGPSecretKeyPassword: String = "", // 发件人私钥密码
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val TAG: String = PgpUtils::class.java.simpleName
|
||||||
|
|
||||||
|
init {
|
||||||
|
Security.addProvider(BouncyCastleProvider())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送明文邮件
|
||||||
|
suspend fun sendPlainEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendPlainEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
Transport.send(originalMessage)
|
||||||
|
Pair(true, "Email sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to send email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送签名后的邮件
|
||||||
|
suspend fun sendSignedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendSignedEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
val signedMessage = getSignedMessage(originalMessage)
|
||||||
|
Transport.send(signedMessage)
|
||||||
|
Pair(true, "Email signed and sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to sign and send email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送加密邮件
|
||||||
|
suspend fun sendEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendEncryptedEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
val producerOptions = ProducerOptions.encrypt(
|
||||||
|
EncryptionOptions.encryptCommunications().addRecipient(recipientPGPPublicKeyRing!!)
|
||||||
|
).setAsciiArmor(true)
|
||||||
|
val encryptedMessage = getEncryptedMessage(originalMessage, producerOptions)
|
||||||
|
Transport.send(encryptedMessage)
|
||||||
|
Pair(true, "Encrypted email sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to send encrypted email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送签名加密邮件
|
||||||
|
suspend fun sendSignedAndEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendSignedAndEncryptedEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
|
||||||
|
val secretKeyDecryptor = SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(senderPGPSecretKeyPassword))
|
||||||
|
val producerOptions = ProducerOptions.signAndEncrypt(
|
||||||
|
EncryptionOptions.encryptCommunications().addRecipient(recipientPGPPublicKeyRing!!),
|
||||||
|
SigningOptions()
|
||||||
|
.addInlineSignature(secretKeyDecryptor, senderPGPSecretKeyRing!!, DocumentSignatureType.CANONICAL_TEXT_DOCUMENT)
|
||||||
|
.overrideHashAlgorithm(HashAlgorithm.SHA256)
|
||||||
|
).setAsciiArmor(true)
|
||||||
|
val encryptedMessage = getEncryptedMessage(originalMessage, producerOptions)
|
||||||
|
Transport.send(encryptedMessage)
|
||||||
|
Pair(true, "Signed and encrypted email sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to send signed and encrypted email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取原始邮件
|
||||||
|
private fun getOriginalMessage(): MimeMessage {
|
||||||
|
val session = Session.getInstance(properties, authenticator)
|
||||||
|
session.debug = true
|
||||||
|
val message = MimeMessage(session)
|
||||||
|
// 设置直接接收者收件箱
|
||||||
|
val toAddress = toAddress.map { InternetAddress(it) }.toTypedArray()
|
||||||
|
message.setRecipients(Message.RecipientType.TO, toAddress)
|
||||||
|
// 设置抄送者收件箱
|
||||||
|
val ccAddress = ccAddress.map { InternetAddress(it) }.toTypedArray()
|
||||||
|
message.setRecipients(Message.RecipientType.CC, ccAddress)
|
||||||
|
// 设置密送者收件箱
|
||||||
|
val bccAddress = bccAddress.map { InternetAddress(it) }.toTypedArray()
|
||||||
|
message.setRecipients(Message.RecipientType.BCC, bccAddress)
|
||||||
|
// 设置发件箱
|
||||||
|
when {
|
||||||
|
nickname.isEmpty() -> message.setFrom(InternetAddress(from))
|
||||||
|
else -> try {
|
||||||
|
var name = nickname.replace(":", "-").replace("\n", "-")
|
||||||
|
name = MimeUtility.encodeText(name)
|
||||||
|
message.setFrom(InternetAddress("$name <$from>"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
message.setFrom(InternetAddress(from))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 邮件主题
|
||||||
|
try {
|
||||||
|
message.subject = MimeUtility.encodeText(subject.replace(":", "-").replace("\n", "-"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
message.subject = subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邮件内容
|
||||||
|
val contentPart = MimeMultipart("mixed")
|
||||||
|
|
||||||
|
// 邮件正文
|
||||||
|
val textBodyPart = MimeBodyPart()
|
||||||
|
textBodyPart.setContent(body, "text/html;charset=UTF-8")
|
||||||
|
contentPart.addBodyPart(textBodyPart)
|
||||||
|
|
||||||
|
// 邮件附件
|
||||||
|
attachFiles.forEach {
|
||||||
|
val fileBodyPart = MimeBodyPart()
|
||||||
|
val ds = FileDataSource(it)
|
||||||
|
val dh = DataHandler(ds)
|
||||||
|
fileBodyPart.dataHandler = dh
|
||||||
|
fileBodyPart.fileName = MimeUtility.encodeText(dh.name)
|
||||||
|
contentPart.addBodyPart(fileBodyPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.setContent(contentPart)
|
||||||
|
message.sentDate = Date()
|
||||||
|
message.saveChanges()
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取签名邮件: https://datatracker.ietf.org/doc/html/rfc3156#autoid-5
|
||||||
|
private fun getSignedMessage(originalMessage: MimeMessage): MimeMessage {
|
||||||
|
// 将原始邮件作为第一个部分添加到 multipart 中
|
||||||
|
val originalBodyPart = MimeBodyPart()
|
||||||
|
originalBodyPart.setContent(originalMessage.content, originalMessage.contentType)
|
||||||
|
|
||||||
|
// 将原始消息写入InputStream
|
||||||
|
val baos = ByteArrayOutputStream()
|
||||||
|
originalBodyPart.writeTo(baos)
|
||||||
|
val inputStream: InputStream = ByteArrayInputStream(baos.toByteArray())
|
||||||
|
|
||||||
|
// 签名数据
|
||||||
|
val secretKeyDecryptor = SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(senderPGPSecretKeyPassword))
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
val encryptionStream = PGPainless.encryptAndOrSign()
|
||||||
|
.onOutputStream(outputStream)
|
||||||
|
.withOptions(
|
||||||
|
ProducerOptions.sign(
|
||||||
|
SigningOptions()
|
||||||
|
.addDetachedSignature(secretKeyDecryptor, senderPGPSecretKeyRing!!, DocumentSignatureType.BINARY_DOCUMENT)
|
||||||
|
.overrideHashAlgorithm(HashAlgorithm.SHA256)
|
||||||
|
).setAsciiArmor(true)
|
||||||
|
)
|
||||||
|
Streams.pipeAll(inputStream, encryptionStream)
|
||||||
|
encryptionStream.close()
|
||||||
|
|
||||||
|
// 签名部分
|
||||||
|
val signaturePart = MimeBodyPart().apply {
|
||||||
|
//dataHandler = DataHandler(ByteArrayDataSource(outputStream.toString(), "application/pgp-signature"))
|
||||||
|
//fileName = "signature.asc"
|
||||||
|
setContent(outputStream.toString(), "application/pgp-signature")
|
||||||
|
//setHeader("Content-Type", "application/pgp-signature; name=\"signature.asc\"")
|
||||||
|
addHeader("Content-Description", "OpenPGP digital signature")
|
||||||
|
addHeader("Content-Disposition", "attachment; filename=\"signature.asc\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
val signedMultiPart = MimeMultipart("signed; micalg=pgp-sha256; protocol=\"application/pgp-signature\"")
|
||||||
|
signedMultiPart.addBodyPart(originalBodyPart, 0)
|
||||||
|
signedMultiPart.addBodyPart(signaturePart, 1)
|
||||||
|
|
||||||
|
val signedMessage = MimeMessage(originalMessage.session)
|
||||||
|
signedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
|
||||||
|
signedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
|
||||||
|
signedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
|
||||||
|
signedMessage.addFrom(originalMessage.from)
|
||||||
|
signedMessage.subject = originalMessage.subject
|
||||||
|
signedMessage.sentDate = originalMessage.sentDate
|
||||||
|
signedMessage.setContent(signedMultiPart)
|
||||||
|
signedMessage.saveChanges()
|
||||||
|
|
||||||
|
return signedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取加密邮件: https://datatracker.ietf.org/doc/html/rfc3156#section-4
|
||||||
|
private fun getEncryptedMessage(originalMessage: MimeMessage, producerOptions: ProducerOptions): MimeMessage {
|
||||||
|
// 将原始消息写入InputStream
|
||||||
|
val baos = ByteArrayOutputStream()
|
||||||
|
originalMessage.writeTo(baos)
|
||||||
|
val inputStream: InputStream = ByteArrayInputStream(baos.toByteArray())
|
||||||
|
|
||||||
|
// 加密数据
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
val encryptionStream = PGPainless.encryptAndOrSign().onOutputStream(outputStream).withOptions(producerOptions)
|
||||||
|
Streams.pipeAll(inputStream, encryptionStream)
|
||||||
|
encryptionStream.close()
|
||||||
|
val result = encryptionStream.result
|
||||||
|
Log.d(TAG, result.toString())
|
||||||
|
|
||||||
|
// The first body part contains the control information necessary to
|
||||||
|
// decrypt the data in the second body part and is labeled according to
|
||||||
|
// the value of the protocol parameter.
|
||||||
|
val versionPart = MimeBodyPart().apply {
|
||||||
|
setText("Version: 1")
|
||||||
|
addHeader("Content-Type", "application/pgp-encrypted")
|
||||||
|
addHeader("Content-Description", "PGP/MIME version identification")
|
||||||
|
//addHeader("Content-Transfer-Encoding", "base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second body part contains the data which was encrypted
|
||||||
|
// and is always labeled application/octet-stream.
|
||||||
|
val encryptedPart = MimeBodyPart().apply {
|
||||||
|
dataHandler = DataHandler(ByteArrayDataSource(outputStream.toByteArray(), "application/octet-stream"))
|
||||||
|
fileName = "encrypted.asc"
|
||||||
|
addHeader("Content-Type", "application/octet-stream; name=\"encrypted.asc\"")
|
||||||
|
addHeader("Content-Description", "OpenPGP encrypted message")
|
||||||
|
addHeader("Content-Disposition", "inline; filename=\"encrypted.asc\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
val encryptedMultiPart = MimeMultipart("encrypted; protocol=\"application/pgp-encrypted\"")
|
||||||
|
encryptedMultiPart.addBodyPart(versionPart, 0)
|
||||||
|
encryptedMultiPart.addBodyPart(encryptedPart, 1)
|
||||||
|
|
||||||
|
val encryptedMessage = MimeMessage(originalMessage.session)
|
||||||
|
encryptedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
|
||||||
|
encryptedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
|
||||||
|
encryptedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
|
||||||
|
encryptedMessage.addFrom(originalMessage.from)
|
||||||
|
encryptedMessage.subject = originalMessage.subject
|
||||||
|
encryptedMessage.sentDate = originalMessage.sentDate
|
||||||
|
encryptedMessage.setContent(encryptedMultiPart)
|
||||||
|
encryptedMessage.saveChanges()
|
||||||
|
|
||||||
|
return encryptedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,251 @@
|
|||||||
|
package com.idormy.sms.forwarder.utils.mail
|
||||||
|
|
||||||
|
import com.idormy.sms.forwarder.utils.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaCertStore
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder
|
||||||
|
import org.bouncycastle.cms.CMSAlgorithm
|
||||||
|
import org.bouncycastle.cms.CMSEnvelopedDataGenerator
|
||||||
|
import org.bouncycastle.cms.CMSProcessableByteArray
|
||||||
|
import org.bouncycastle.cms.CMSSignedDataGenerator
|
||||||
|
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder
|
||||||
|
import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder
|
||||||
|
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import org.bouncycastle.operator.OutputEncryptor
|
||||||
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
|
||||||
|
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.Security
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Properties
|
||||||
|
import javax.activation.DataHandler
|
||||||
|
import javax.activation.FileDataSource
|
||||||
|
import javax.mail.Authenticator
|
||||||
|
import javax.mail.Message
|
||||||
|
import javax.mail.Session
|
||||||
|
import javax.mail.Transport
|
||||||
|
import javax.mail.internet.InternetAddress
|
||||||
|
import javax.mail.internet.MimeBodyPart
|
||||||
|
import javax.mail.internet.MimeMessage
|
||||||
|
import javax.mail.internet.MimeMultipart
|
||||||
|
import javax.mail.internet.MimeUtility
|
||||||
|
|
||||||
|
@Suppress("PrivatePropertyName", "unused")
|
||||||
|
class SmimeUtils(
|
||||||
|
private val properties: Properties,
|
||||||
|
private val authenticator: Authenticator,
|
||||||
|
// 邮件参数
|
||||||
|
private val from: String, // 发件人邮箱
|
||||||
|
private val nickname: String, // 发件人昵称
|
||||||
|
private val subject: String, // 邮件主题
|
||||||
|
private val body: String, // 邮件正文
|
||||||
|
private val attachFiles: MutableList<File> = mutableListOf(), // 附件
|
||||||
|
// 收件人参数
|
||||||
|
private val toAddress: MutableList<String> = mutableListOf(), // 收件人邮箱
|
||||||
|
private val ccAddress: MutableList<String> = mutableListOf(), // 抄送者邮箱
|
||||||
|
private val bccAddress: MutableList<String> = mutableListOf(), // 密送者邮箱
|
||||||
|
// 邮件 S/MIME 加密和签名
|
||||||
|
private val recipientX509Cert: X509Certificate? = null, //收件人公钥(用于加密)
|
||||||
|
private val senderPrivateKey: PrivateKey? = null, //发件人私玥(用于签名)
|
||||||
|
private val senderX509Cert: X509Certificate? = null, //发件人公玥(用于签名)
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val TAG: String = SmimeUtils::class.java.simpleName
|
||||||
|
|
||||||
|
init {
|
||||||
|
Security.addProvider(BouncyCastleProvider())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送明文邮件
|
||||||
|
suspend fun sendPlainEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendPlainEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
Transport.send(originalMessage)
|
||||||
|
Pair(true, "Email sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to send email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送签名后的邮件
|
||||||
|
suspend fun sendSignedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendSignedEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
val signedMessage = getSignedMessage(originalMessage)
|
||||||
|
Transport.send(signedMessage)
|
||||||
|
Pair(true, "Email signed and sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to sign and send email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送加密邮件
|
||||||
|
suspend fun sendEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendEncryptedEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
val encryptedMessage = getEncryptedMessage(originalMessage)
|
||||||
|
Transport.send(encryptedMessage)
|
||||||
|
Pair(true, "Encrypted email sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to send encrypted email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送签名加密邮件
|
||||||
|
suspend fun sendSignedAndEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "sendSignedAndEncryptedEmail")
|
||||||
|
try {
|
||||||
|
val originalMessage = getOriginalMessage()
|
||||||
|
val signedMessage = getSignedMessage(originalMessage)
|
||||||
|
val encryptedMessage = getEncryptedMessage(signedMessage)
|
||||||
|
Transport.send(encryptedMessage)
|
||||||
|
Pair(true, "Signed and encrypted email sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Pair(false, "Failed to send signed and encrypted email: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取原始邮件
|
||||||
|
private fun getOriginalMessage(): MimeMessage {
|
||||||
|
val session = Session.getInstance(properties, authenticator)
|
||||||
|
session.debug = true
|
||||||
|
val message = MimeMessage(session)
|
||||||
|
// 设置直接接收者收件箱
|
||||||
|
val toAddress = toAddress.map { InternetAddress(it) }.toTypedArray()
|
||||||
|
message.setRecipients(Message.RecipientType.TO, toAddress)
|
||||||
|
// 设置抄送者收件箱
|
||||||
|
val ccAddress = ccAddress.map { InternetAddress(it) }.toTypedArray()
|
||||||
|
message.setRecipients(Message.RecipientType.CC, ccAddress)
|
||||||
|
// 设置密送者收件箱
|
||||||
|
val bccAddress = bccAddress.map { InternetAddress(it) }.toTypedArray()
|
||||||
|
message.setRecipients(Message.RecipientType.BCC, bccAddress)
|
||||||
|
// 设置发件箱
|
||||||
|
when {
|
||||||
|
nickname.isEmpty() -> message.setFrom(InternetAddress(from))
|
||||||
|
else -> try {
|
||||||
|
var name = nickname.replace(":", "-").replace("\n", "-")
|
||||||
|
name = MimeUtility.encodeText(name)
|
||||||
|
message.setFrom(InternetAddress("$name <$from>"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
message.setFrom(InternetAddress(from))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 邮件主题
|
||||||
|
try {
|
||||||
|
message.subject = MimeUtility.encodeText(subject.replace(":", "-").replace("\n", "-"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
message.subject = subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邮件内容
|
||||||
|
val contentPart = MimeMultipart("mixed")
|
||||||
|
|
||||||
|
// 邮件正文
|
||||||
|
val textBodyPart = MimeBodyPart()
|
||||||
|
textBodyPart.setContent(body, "text/html;charset=UTF-8")
|
||||||
|
contentPart.addBodyPart(textBodyPart)
|
||||||
|
|
||||||
|
// 邮件附件
|
||||||
|
attachFiles.forEach {
|
||||||
|
val fileBodyPart = MimeBodyPart()
|
||||||
|
val ds = FileDataSource(it)
|
||||||
|
val dh = DataHandler(ds)
|
||||||
|
fileBodyPart.dataHandler = dh
|
||||||
|
fileBodyPart.fileName = MimeUtility.encodeText(dh.name)
|
||||||
|
contentPart.addBodyPart(fileBodyPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.setContent(contentPart)
|
||||||
|
message.sentDate = Date()
|
||||||
|
message.saveChanges()
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取签名邮件
|
||||||
|
private fun getSignedMessage(originalMessage: MimeMessage): MimeMessage {
|
||||||
|
// 创建签名者信息生成器
|
||||||
|
val contentSigner = JcaContentSignerBuilder("SHA256withRSA").build(senderPrivateKey)
|
||||||
|
val certificateHolder = JcaX509CertificateHolder(senderX509Cert)
|
||||||
|
val signerInfoGenerator = JcaSignerInfoGeneratorBuilder(
|
||||||
|
JcaDigestCalculatorProviderBuilder().setProvider(BouncyCastleProvider()).build()
|
||||||
|
).build(contentSigner, certificateHolder)
|
||||||
|
|
||||||
|
// 创建 CMSSignedDataGenerator 并添加签名者信息和证书
|
||||||
|
val generator = CMSSignedDataGenerator()
|
||||||
|
generator.addSignerInfoGenerator(signerInfoGenerator)
|
||||||
|
val certStore = JcaCertStore(listOf(senderX509Cert))
|
||||||
|
generator.addCertificates(certStore)
|
||||||
|
|
||||||
|
// 将邮件内容转换为 CMSSignedData
|
||||||
|
//val originalContent = originalMessage.content as MimeMultipart //TODO: Outlook 不显示正文
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
//originalContent.writeTo(outputStream)
|
||||||
|
originalMessage.writeTo(outputStream) //TODO: Thunderbird 会重复现实发件人
|
||||||
|
val contentData = CMSProcessableByteArray(outputStream.toByteArray())
|
||||||
|
val signedData = generator.generate(contentData, true)
|
||||||
|
|
||||||
|
// 创建 MimeMessage 并设置签名后的内容
|
||||||
|
val signedMessage = MimeMessage(originalMessage.session, ByteArrayInputStream(signedData.encoded))
|
||||||
|
signedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
|
||||||
|
signedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
|
||||||
|
signedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
|
||||||
|
signedMessage.addFrom(originalMessage.from)
|
||||||
|
signedMessage.subject = originalMessage.subject
|
||||||
|
signedMessage.sentDate = originalMessage.sentDate
|
||||||
|
signedMessage.setContent(signedData.encoded, "application/pkcs7-mime; name=smime.p7m; smime-type=signed-data")
|
||||||
|
signedMessage.saveChanges()
|
||||||
|
|
||||||
|
return signedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取加密邮件
|
||||||
|
private fun getEncryptedMessage(originalMessage: MimeMessage): MimeMessage {
|
||||||
|
// 使用收件人的证书进行加密
|
||||||
|
val cmsEnvelopedDataGenerator = CMSEnvelopedDataGenerator()
|
||||||
|
val recipientInfoGenerator = JceKeyTransRecipientInfoGenerator(recipientX509Cert)
|
||||||
|
cmsEnvelopedDataGenerator.addRecipientInfoGenerator(recipientInfoGenerator)
|
||||||
|
|
||||||
|
// 使用 3DES 加密
|
||||||
|
val outputEncryptor: OutputEncryptor = JceCMSContentEncryptorBuilder(CMSAlgorithm.DES_EDE3_CBC).build()
|
||||||
|
val originalContent = ByteArrayOutputStream()
|
||||||
|
originalMessage.writeTo(originalContent)
|
||||||
|
val inputStream = originalContent.toByteArray()
|
||||||
|
val cmsEnvelopedData = cmsEnvelopedDataGenerator.generate(
|
||||||
|
CMSProcessableByteArray(inputStream),
|
||||||
|
outputEncryptor
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建加密邮件
|
||||||
|
val encryptedMessage = MimeMessage(originalMessage.session)
|
||||||
|
encryptedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
|
||||||
|
encryptedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
|
||||||
|
encryptedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
|
||||||
|
encryptedMessage.addFrom(originalMessage.from)
|
||||||
|
encryptedMessage.subject = originalMessage.subject
|
||||||
|
encryptedMessage.sentDate = originalMessage.sentDate
|
||||||
|
encryptedMessage.setContent(cmsEnvelopedData.encoded, "application/pkcs7-mime; name=smime.p7m; smime-type=enveloped-data")
|
||||||
|
encryptedMessage.setHeader("Content-Type", "application/pkcs7-mime; name=smime.p7m; smime-type=enveloped-data")
|
||||||
|
encryptedMessage.setHeader("Content-Disposition", "attachment; filename=smime.p7m")
|
||||||
|
encryptedMessage.setHeader("Content-Description", "S/MIME Encrypted Message")
|
||||||
|
encryptedMessage.addHeader("Content-Transfer-Encoding", "base64")
|
||||||
|
encryptedMessage.saveChanges()
|
||||||
|
|
||||||
|
return encryptedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,129 @@
|
|||||||
|
<?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="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="@dimen/config_margin_5dp"
|
||||||
|
android:background="?attr/xui_config_color_separator_light" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/recipient_email"
|
||||||
|
android:textSize="@dimen/text_size_small"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
|
||||||
|
android:id="@+id/et_recipient_email"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="5dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textSize="@dimen/text_size_small"
|
||||||
|
app:met_clearButton="true" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_del"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="5dp"
|
||||||
|
android:contentDescription="@string/del"
|
||||||
|
android:src="@drawable/ic_delete"
|
||||||
|
app:tint="#F15C58" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layout_recipient_keystore"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/keystore_path"
|
||||||
|
android:textSize="@dimen/text_size_small"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
|
||||||
|
android:id="@+id/et_recipient_keystore"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="5dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/keystore_path_tips"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textSize="@dimen/text_size_small"
|
||||||
|
app:met_clearButton="true" />
|
||||||
|
|
||||||
|
<com.xuexiang.xui.widget.button.shadowbutton.RippleShadowShadowButton
|
||||||
|
android:id="@+id/btn_file_picker"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="5dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="5dp"
|
||||||
|
android:text="@string/select_file"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="@dimen/text_size_mini"
|
||||||
|
app:sb_color_unpressed="@color/colorBlueGrey"
|
||||||
|
app:sb_ripple_color="@color/white"
|
||||||
|
app:sb_ripple_duration="500"
|
||||||
|
app:sb_shape_type="rectangle"
|
||||||
|
tools:ignore="SmallSp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/keystore_password"
|
||||||
|
android:textSize="@dimen/text_size_small"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
|
||||||
|
android:id="@+id/et_recipient_password"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="5dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/keystore_password_tips"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textSize="@dimen/text_size_small"
|
||||||
|
app:met_passWordButton="true" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
Loading…
Reference in New Issue