diff --git a/app/build.gradle b/app/build.gradle index 9cacb71b..3b1a38f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -25,6 +25,17 @@ if (isNeedPackage.toBoolean() && isUseBooster.toBoolean()) { } android { + // 禁用过时 API 警告 + configure(allprojects) { + gradle.projectsEvaluated { + tasks.withType(JavaCompile).tap { + configureEach { + options.compilerArgs << "-Xlint:-removal" + } + } + } + } + buildToolsVersion build_versions.build_tools compileSdkVersion build_versions.target_sdk @@ -162,10 +173,15 @@ android { exclude 'lib/x86/libgojni.so' exclude 'lib/x86_64/libgojni.so' } + jniLibs { + excludes += ["kotlin/**"] + } resources { + merge 'META-INF/mailcap' pickFirst 'META-INF/LICENSE.md' pickFirst 'META-INF/NOTICE.md' excludes += ['META-INF/DEPENDENCIES.txt', 'META-INF/LICENSE.txt', 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/LICENSE', 'META-INF/DEPENDENCIES', 'META-INF/notice.txt', 'META-INF/license.txt', 'META-INF/dependencies.txt', 'META-INF/LGPL2.1'] + excludes += ["META-INF/*.kotlin_module", "META-INF/*.version", "kotlin/**", "DebugProbesKt.bin"] } } @@ -208,21 +224,23 @@ android { } namespace 'com.idormy.sms.forwarder' - //编译前清理项目缓存 - preBuild.dependsOn clean - //编译后清理垃圾文件 - gradle.buildFinished { buildResult -> - if (buildResult.failure == null) { - println "Build succeeded, cleaning text files..." - //delete rootProject.buildDir - FileTree rootTree = fileTree(dir: rootDir) - rootTree.each { File file -> - if ((file.toString().contains("ajcore") || file.toString().contains("mapping") || file.toString().contains("seeds") || file.toString().contains("unused")) && file.toString().endsWith(".txt")) { - delete file + if (isNeedClean.toBoolean()) { + //编译前清理项目缓存 + preBuild.dependsOn clean + //编译后清理垃圾文件 + gradle.buildFinished { buildResult -> + if (buildResult.failure == null) { + println "Build succeeded, cleaning text files..." + //delete rootProject.buildDir + FileTree rootTree = fileTree(dir: rootDir) + rootTree.each { File file -> + if ((file.toString().contains("ajcore") || file.toString().contains("mapping") || file.toString().contains("seeds") || file.toString().contains("unused")) && file.toString().endsWith(".txt")) { + delete file + } } + } else { + println "Build failed, cleanTxt not executed." } - } else { - println "Build failed, cleanTxt not executed." } } } @@ -305,8 +323,11 @@ dependencies { //implementation 'com.github.tiagohm.MarkdownView:emoji:0.19.0' def retrofit2_version = '2.9.0' + //noinspection GradleDependency implementation "com.squareup.retrofit2:retrofit:$retrofit2_version" + //noinspection GradleDependency implementation "com.squareup.retrofit2:converter-gson:$retrofit2_version" + //noinspection GradleDependency implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit2_version" def paging_version = "3.1.1" @@ -325,6 +346,18 @@ dependencies { implementation "com.sun.mail:android-mail:$mail_version" implementation "com.sun.mail:android-activation:$mail_version" + //国密算法SM4 的JAVA实现(基于BC实现) + def bouncycastle_version = '1.77' + api "org.bouncycastle:bcprov-jdk18on:$bouncycastle_version" + //邮件 S/MIME 加密和签名 + //implementation "org.spongycastle:bcmail-jdk18on:$bouncycastle_version" //Android下报错 + implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastle_version" + //implementation "org.bouncycastle:bctls-jdk18on:$bouncycastle_version" + //邮件 PGP 加密和签名 + //implementation "org.bouncycastle:bcpg-jdk18on:$bouncycastle_version" //Thunderbird无法解密 + //PGPainless: https://github.com/pgpainless/pgpainless + implementation 'org.pgpainless:pgpainless-core:1.6.7' + //Android Keep Alive(安卓保活),Cactus 集成双进程前台服务,JobScheduler,onePix(一像素),WorkManager,无声音乐 //https://github.com/gyf-dev/Cactus implementation 'com.gyf.cactus:cactus:1.1.3-beta13' @@ -333,9 +366,6 @@ dependencies { implementation 'cn.ppps.andserver:api:2.1.12' kapt 'cn.ppps.andserver:processor:2.1.12' - //国密算法SM4 的JAVA实现(基于BC实现) - api 'org.bouncycastle:bcprov-jdk15on:1.70' - //Location 是一个通过 Android 自带的 LocationManager 来实现的定位功能:https://github.com/jenly1314/Location implementation 'com.github.pppscn:location:1.0.0' diff --git a/app/src/main/java/com/idormy/sms/forwarder/entity/setting/EmailSetting.kt b/app/src/main/java/com/idormy/sms/forwarder/entity/setting/EmailSetting.kt index b1e40b08..931c691a 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/entity/setting/EmailSetting.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/entity/setting/EmailSetting.kt @@ -1,5 +1,6 @@ package com.idormy.sms.forwarder.entity.setting +import com.idormy.sms.forwarder.R import java.io.Serializable data class EmailSetting( @@ -11,6 +12,19 @@ data class EmailSetting( var port: String? = "", var ssl: Boolean? = false, var startTls: Boolean? = false, - var toEmail: String? = "", var title: String? = "", -) : Serializable \ No newline at end of file + var recipients: MutableMap> = mutableMapOf(), + var toEmail: String? = "", + var keystore: String? = "", + var password: String? = "", + var encryptionProtocol: String = "Plain", //加密协议: S/MIME、OpenPGP、Plain(不传证书) +) : Serializable { + + fun getEncryptionProtocolCheckId(): Int { + return when (encryptionProtocol) { + "S/MIME" -> R.id.rb_encryption_protocol_smime + "OpenPGP" -> R.id.rb_encryption_protocol_openpgp + else -> R.id.rb_encryption_protocol_plain + } + } +} diff --git a/app/src/main/java/com/idormy/sms/forwarder/fragment/senders/EmailFragment.kt b/app/src/main/java/com/idormy/sms/forwarder/fragment/senders/EmailFragment.kt index a042fdc9..ca566723 100644 --- a/app/src/main/java/com/idormy/sms/forwarder/fragment/senders/EmailFragment.kt +++ b/app/src/main/java/com/idormy/sms/forwarder/fragment/senders/EmailFragment.kt @@ -1,12 +1,20 @@ package com.idormy.sms.forwarder.fragment.senders +import android.annotation.SuppressLint +import android.os.Environment import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Button import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout import androidx.fragment.app.viewModels import com.google.gson.Gson +import com.hjq.permissions.OnPermissionCallback +import com.hjq.permissions.Permission +import com.hjq.permissions.XXPermissions import com.idormy.sms.forwarder.R import com.idormy.sms.forwarder.core.BaseFragment import com.idormy.sms.forwarder.core.Core @@ -41,6 +49,13 @@ import io.reactivex.SingleObserver import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import org.pgpainless.PGPainless +import org.pgpainless.key.info.KeyRingInfo +import java.io.File +import java.io.FileInputStream +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.util.Date @Page(name = "Email") @@ -52,6 +67,11 @@ class EmailFragment : BaseFragment(), View.OnClick private val viewModel by viewModels { BaseViewModelFactory(context) } private var mCountDownHelper: CountDownButtonHelper? = null private var mailType: String = getString(R.string.other_mail_type) //邮箱类型 + private var recipientItemMap: MutableMap = mutableMapOf() + private val downloadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path + + //加密协议: S/MIME、OpenPGP、Plain(不传证书) + private var encryptionProtocol: String = "Plain" @JvmField @AutoWired(name = KEY_SENDER_ID) @@ -98,7 +118,6 @@ class EmailFragment : BaseFragment(), View.OnClick }) val mailTypeArray = getStringArray(R.array.MailType) - Log.d(TAG, mailTypeArray.toString()) binding!!.spMailType.setOnItemSelectedListener { _: MaterialSpinner?, position: Int, _: Long, item: Any -> mailType = item.toString() //XToastUtils.warning(mailType) @@ -112,6 +131,39 @@ class EmailFragment : BaseFragment(), View.OnClick binding!!.spMailType.selectedIndex = mailTypeArray.size - 1 binding!!.layoutServiceSetting.visibility = View.VISIBLE + binding!!.rgEncryptionProtocol.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.rb_encryption_protocol_smime -> { + encryptionProtocol = "S/MIME" + binding!!.layoutSenderKeystore.visibility = View.VISIBLE + binding!!.tvSenderKeystore.text = getString(R.string.sender_smime_keystore) + binding!!.tvEmailTo.text = getString(R.string.email_to_smime) + binding!!.tvEmailToTips.text = getString(R.string.email_to_smime_tips) + } + + R.id.rb_encryption_protocol_openpgp -> { + encryptionProtocol = "OpenPGP" + binding!!.layoutSenderKeystore.visibility = View.VISIBLE + binding!!.tvSenderKeystore.text = getString(R.string.sender_openpgp_keystore) + binding!!.tvEmailTo.text = getString(R.string.email_to_openpgp) + binding!!.tvEmailToTips.text = getString(R.string.email_to_openpgp_tips) + } + + else -> { + encryptionProtocol = "Plain" + binding!!.layoutSenderKeystore.visibility = View.GONE + binding!!.tvEmailTo.text = getString(R.string.email_to) + binding!!.tvEmailToTips.text = getString(R.string.email_to_tips) + } + } + + //遍历 layout_recipients 子元素,设置 layout_recipient_keystore 可见性 + for (recipientItem in recipientItemMap.values) { + val layoutRecipientKeystore = recipientItem.findViewById(R.id.layout_recipient_keystore) + layoutRecipientKeystore.visibility = if (encryptionProtocol == "Plain") View.GONE else View.VISIBLE + } + } + //新增 if (senderId <= 0) { titleBar?.setSubTitle(getString(R.string.add_sender)) @@ -143,6 +195,10 @@ class EmailFragment : BaseFragment(), View.OnClick if (settingVo != null) { if (!TextUtils.isEmpty(settingVo.mailType)) { mailType = settingVo.mailType.toString() + //TODO: 替换mailType为当前语言,避免切换语言后失效,历史包袱怎么替换比较优雅? + if (mailType == "other" || mailType == "其他邮箱" || mailType == "其他郵箱") { + mailType = getString(R.string.other_mail_type) + } binding!!.spMailType.setSelectedItem(mailType) if (mailType != getString(R.string.other_mail_type)) { binding!!.layoutServiceSetting.visibility = View.GONE @@ -155,8 +211,24 @@ class EmailFragment : BaseFragment(), View.OnClick binding!!.etPort.setText(settingVo.port) binding!!.sbSsl.isChecked = settingVo.ssl == true binding!!.sbStartTls.isChecked = settingVo.startTls == true - binding!!.etToEmail.setText(settingVo.toEmail) binding!!.etTitleTemplate.setText(settingVo.title) + encryptionProtocol = settingVo.encryptionProtocol + binding!!.rgEncryptionProtocol.check(settingVo.getEncryptionProtocolCheckId()) + if (settingVo.recipients.isNotEmpty()) { + for ((email, cert) in settingVo.recipients) { + addRecipientItem(email, cert) + } + } else { + //兼容旧版本 + val emails = settingVo.toEmail?.split(",") + if (!emails.isNullOrEmpty()) { + for (email in emails.toTypedArray()) { + addRecipientItem(email) + } + } + } + binding!!.etSenderKeystore.setText(settingVo.keystore) + binding!!.etSenderPassword.setText(settingVo.password) } } }) @@ -174,6 +246,12 @@ class EmailFragment : BaseFragment(), View.OnClick binding!!.btnTest.setOnClickListener(this) binding!!.btnDel.setOnClickListener(this) binding!!.btnSave.setOnClickListener(this) + binding!!.btnAddRecipient.setOnClickListener { + addRecipientItem() + } + binding!!.btnSenderKeystorePicker.setOnClickListener { + pickCert(binding!!.etSenderKeystore) + } LiveEventBus.get(KEY_SENDER_TEST, String::class.java).observe(this) { mCountDownHelper?.finish() } } @@ -284,21 +362,256 @@ class EmailFragment : BaseFragment(), View.OnClick private fun checkSetting(): EmailSetting { val fromEmail = binding!!.etFromEmail.text.toString().trim() val pwd = binding!!.etPwd.text.toString().trim() - val nickname = binding!!.etNickname.text.toString().trim() + val recipients = getRecipientsFromRecipientItemMap() + if (TextUtils.isEmpty(fromEmail) || TextUtils.isEmpty(pwd) || recipients.isEmpty()) { + throw Exception(getString(R.string.invalid_email)) + } + for ((email, cert) in recipients) { + if (!CommonUtils.checkEmail(email)) { + throw Exception(String.format(getString(R.string.invalid_recipient_email), email)) + } + Log.d(TAG, "email: $email, cert: $cert") + when (encryptionProtocol) { + "S/MIME" -> { + when { + cert.first.isNotEmpty() && cert.second.isNotEmpty() -> { + try { + // 判断是否有效的PKCS12私钥证书 + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(FileInputStream(cert.first), cert.second.toCharArray()) + val alias = keyStore.aliases().nextElement() + val recipientPublicKey = keyStore.getCertificate(alias) as X509Certificate + Log.d(TAG, "PKCS12 Certificate: $recipientPublicKey") + } catch (e: Exception) { + e.printStackTrace() + throw Exception(String.format(getString(R.string.invalid_pkcs12_certificate), email)) + } + } + + cert.first.isNotEmpty() && cert.second.isEmpty() -> { + try { + // 判断是否有效的X.509公钥证书 + val certFactory = CertificateFactory.getInstance("X.509") + val fileInputStream = FileInputStream(cert.first) + val recipientPublicKey = certFactory.generateCertificate(fileInputStream) as X509Certificate + Log.d(TAG, "X.509 Certificate: $recipientPublicKey") + } catch (e: Exception) { + e.printStackTrace() + throw Exception(String.format(getString(R.string.invalid_x509_certificate), email)) + } + } + } + } + + "OpenPGP" -> { + when { + cert.first.isNotEmpty() && cert.second.isNotEmpty() -> { + try { + //从私钥证书文件提取公钥 + val recipientPrivateKeyStream = FileInputStream(cert.first) + val recipientPGPSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(recipientPrivateKeyStream) + val recipientPGPPublicKeyRing = PGPainless.extractCertificate(recipientPGPSecretKeyRing!!) + val keyInfo = KeyRingInfo(recipientPGPPublicKeyRing) + Log.d(TAG, "recipientPGPPublicKeyRing: $keyInfo") + } catch (e: Exception) { + e.printStackTrace() + throw Exception(String.format(getString(R.string.invalid_x509_certificate), email)) + } + } + + cert.first.isNotEmpty() && cert.second.isEmpty() -> { + try { + //从证书文件提取公钥 + val recipientPublicKeyStream = FileInputStream(cert.first) + val recipientPGPPublicKeyRing = PGPainless.readKeyRing().publicKeyRing(recipientPublicKeyStream) + val keyInfo = KeyRingInfo(recipientPGPPublicKeyRing!!) + Log.d(TAG, "recipientPGPPublicKeyRing: $keyInfo") + } catch (e: Exception) { + e.printStackTrace() + throw Exception(String.format(getString(R.string.invalid_x509_certificate), email)) + } + } + } + } + } + } val host = binding!!.etHost.text.toString().trim() val port = binding!!.etPort.text.toString().trim() + if (mailType == getString(R.string.other_mail_type) && (TextUtils.isEmpty(host) || TextUtils.isEmpty(port))) { + throw Exception(getString(R.string.invalid_email_server)) + } + + val nickname = binding!!.etNickname.text.toString().trim() val ssl = binding!!.sbSsl.isChecked val startTls = binding!!.sbStartTls.isChecked - val toEmail = binding!!.etToEmail.text.toString().trim() val title = binding!!.etTitleTemplate.text.toString().trim() - if (TextUtils.isEmpty(fromEmail) || TextUtils.isEmpty(pwd) || TextUtils.isEmpty(toEmail)) { - throw Exception(getString(R.string.invalid_email)) + val keystore = binding!!.etSenderKeystore.text.toString().trim() + val password = binding!!.etSenderPassword.text.toString().trim() + if (keystore.isNotEmpty()) { + val senderPrivateKeyStream = FileInputStream(keystore) + if (senderPrivateKeyStream.available() <= 0) { + throw Exception(getString(R.string.invalid_sender_keystore)) + } + when (encryptionProtocol) { + "S/MIME" -> { + try { + // 判断是否有效的PKCS12私钥证书 + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(senderPrivateKeyStream, password.toCharArray()) + val alias = keyStore.aliases().nextElement() + val certificate = keyStore.getCertificate(alias) as X509Certificate + Log.d(TAG, "PKCS12 Certificate: $certificate") + } catch (e: Exception) { + e.printStackTrace() + throw Exception(getString(R.string.invalid_sender_keystore)) + } + } + + "OpenPGP" -> { + try { + val senderPGPSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(senderPrivateKeyStream) + val keyInfo = KeyRingInfo(senderPGPSecretKeyRing!!) + Log.d(TAG, "senderPGPSecretKeyRing: $keyInfo") + } catch (e: Exception) { + e.printStackTrace() + throw Exception(getString(R.string.invalid_sender_keystore)) + } + } + } + } - if (mailType == getString(R.string.other_mail_type) && (TextUtils.isEmpty(host) || TextUtils.isEmpty(port))) { - throw Exception(getString(R.string.invalid_email_server)) + + return EmailSetting(mailType, fromEmail, pwd, nickname, host, port, ssl, startTls, title, recipients, "", keystore, password, encryptionProtocol) + } + + //recipient序号 + private var recipientItemId = 0 + + /** + * 动态增删recipient + * + * @param email recipient的email + * @param cert recipient的cert,为空则不设置 + */ + private fun addRecipientItem(email: String = "", cert: Any? = null) { + val itemAddRecipient = View.inflate(requireContext(), R.layout.item_add_recipient, null) as LinearLayout + val etRecipientEmail = itemAddRecipient.findViewById(R.id.et_recipient_email) + val etRecipientKeystore = itemAddRecipient.findViewById(R.id.et_recipient_keystore) + val etRecipientPassword = itemAddRecipient.findViewById(R.id.et_recipient_password) + etRecipientEmail.setText(email) + Log.d(TAG, "cert: $cert") + when (cert) { + is String -> etRecipientKeystore.setText(cert) + is Pair<*, *> -> { + Log.d(TAG, "cert.first: ${cert.first}") + Log.d(TAG, "cert.second: ${cert.second}") + etRecipientKeystore.setText(cert.first.toString()) + etRecipientPassword.setText(cert.second.toString()) + } + } + + val ivDel = itemAddRecipient.findViewById(R.id.iv_del) + ivDel.tag = recipientItemId + ivDel.setOnClickListener { + val itemId = it.tag as Int + binding!!.layoutRecipients.removeView(recipientItemMap[itemId]) + recipientItemMap.remove(itemId) + } + + val btnFilePicker = itemAddRecipient.findViewById