Android 邮件发送日志

做这个功能的背景是因为装了测试包的张三李四王五说他碰到了Bug,具体原因说不上来,所以不如把日志发送到开发者的邮箱,这样方便开发人员排查。

一、准备工作

  • 项目中有完善的日志打印、分级、输出到文件的能力。开发人员平时要养成良好的关键地方增加日志的习惯,不然碰到问题可能也抓不到。
  • 准备一个163邮箱,其他邮箱也可以,需要支持SMTP即可,后面都以163邮箱为例。

二、获取开启SMTP时的密码

1、网页登录163邮箱,点击设置->POP3/SMTP/IMAP

2、IMAP/SMTP服务点击开启

3、开启后会弹个窗告诉你授权密码,这个只显示一次,复制出来,另外这个授权码只有180天的有效期,到期记得续。

三、具体实现

1、具体实现是通过JavaMail API的方案。导包:

kotlin 复制代码
implementation 'com.sun.mail:android-mail:1.6.6'
implementation 'com.sun.mail:android-activation:1.6.6'

2、工具类代码

具体实现如下,把buildConfig中的参数改一下就能用,代码自取

kotlin 复制代码
import android.content.Context
import android.os.Build
import com.foxxusa.beaver.BuildConfig
import com.foxxusa.beaver.deps.account.AccountManager
import com.foxxusa.beaver.infra.common.App
import com.foxxusa.beaver.infra.log.FoxxLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.*
import javax.activation.CommandMap
import javax.activation.DataHandler
import javax.activation.FileDataSource
import javax.activation.MailcapCommandMap
import javax.mail.*
import javax.mail.internet.*


/**
 * @description 将邮件以日志发送的工具类
 */

object LogMailSender {

    //多个收件人用空格分隔
    private const val TO_EMAIL_ADDRESS = "your.email.one@qq.com your.email.two@qq.com"

    /**
     * 发送日志目录所有文件
     * @param context Android上下文(用于获取文件路径)
     * @param listener 发送结果回调
     */
    suspend fun sendLogDirectory(context: Context, listener: OnSendListener) {
        //创建配置
        buildMailCap()
        val config = buildConfig(listener)
        //日志存放的文件夹,根据自己的目录修改
        val logDir = File(context.filesDir, "log")
        if (!logDir.exists() || !logDir.isDirectory) {
            FoxxLog.w("Log directory not found: " + logDir.absolutePath)
            return
        }

        val logFiles = logDir.listFiles()
        if (logFiles == null || logFiles.isEmpty()) {
            FoxxLog.w("Log directory is empty")
            return
        }

        // 异步发送
        withContext(Dispatchers.IO) {
            try {
                sendEmail(config, listOf(*logFiles))
                if (config.listener != null) config.listener!!.onSuccess()
            } catch (e: Exception) {
                FoxxLog.e(e, "Email sending failed")
                if (config.listener != null) config.listener!!.onFailure(e)
            }
        }
    }

    //创建配置
    private fun buildConfig(listener: OnSendListener): EmailConfig {
        return EmailConfig()
            .setHost("smtp.163.com")
            .setPort("465")
            .setUsername("your.email@163.com") //上面开启SMTP的163邮箱
            .setPassword("JZSxxxxxxxx")   //开启SMTP时的密码,只有180天有效期
            .setFrom("your.email@163.com")   //上面开启SMTP的163邮箱
            .setTo(TO_EMAIL_ADDRESS)   //收件人,多个收件人用空格隔开
            .setSubject("Android client log report")   //邮件主题
            .setBody("Please find the attached log file collected automatically by the Android client.")
            .setSendListener(listener)  //回调监听
    }

    private fun buildMailCap() {
        val mc: MailcapCommandMap = CommandMap.getDefaultCommandMap() as MailcapCommandMap
        mc.addMailcap("text/html;; x-java-content-handler=com.sun.mail.handlers.text_html")
        mc.addMailcap("text/xml;; x-java-content-handler=com.sun.mail.handlers.text_xml")
        mc.addMailcap("text/plain;; x-java-content-handler=com.sun.mail.handlers.text_plain")
        mc.addMailcap("multipart/*;; x-java-content-handler=com.sun.mail.handlers.multipart_mixed")
        mc.addMailcap("message/rfc822;; x-java-content-handler=com.sun.mail.handlers.message_rfc822")
        CommandMap.setDefaultCommandMap(mc)
    }

    /**
     * 执行邮件发送
     */
    @Throws(MessagingException::class)
    private fun sendEmail(config: EmailConfig, attachments: List<File>) {
        //配置SMTP会话
        val props = Properties()
        props["mail.smtp.host"] = config.host
        props["mail.smtp.port"] = config.port
        props["mail.smtp.auth"] = "true"
        props["mail.smtp.ssl.enable"] = "true" // 强制SSL加密
        props["mail.smtp.socketFactory.class"] = "javax.net.ssl.SSLSocketFactory"

        val session = Session.getInstance(props, object : Authenticator() {
            override fun getPasswordAuthentication(): PasswordAuthentication {
                return PasswordAuthentication(config.username, config.password)
            }
        })
        session.debug = false //调试日志,调试时可以开启

        //构建邮件内容
        val message = MimeMessage(session)
        message.setFrom(InternetAddress(config.from))
        message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(config.to, false))
        message.subject = config.subject

        //创建多部分内容(正文+附件)
        val multipart: Multipart = MimeMultipart()

        //添加HTML正文
        val textPart = MimeBodyPart()
        val pi = App.context.packageManager.getPackageInfo(App.context.packageName, 0)
        val htmlContent = """
                <p>${config.body}</p>
                <p>Contains ${attachments.size} log file(s)</p>
                <p>Package: ${App.context.packageName}</p>
                <p>User: ${AccountManager.get.getUserInfo().email}</p>
                <p>Environment: ${BuildConfig.BUILD_TYPE.uppercase()}</p>
                <p>Device: ${Build.MANUFACTURER} ${ Build.MODEL}</p>
                <p>Android: ${ Build.VERSION.RELEASE} (SDK ${ Build.VERSION.SDK_INT})</p>
                <p>App version: ${pi.versionName} (${pi.versionCode})</p>
            """.trimIndent()
        textPart.setContent(htmlContent, "text/html; charset=utf-8")
        multipart.addBodyPart(textPart)

        //添加所有附件
        for (file in attachments) {
            if (!file.exists()) continue

            val attachmentPart = MimeBodyPart()
            val source = FileDataSource(file)
            attachmentPart.dataHandler = DataHandler(source)
            attachmentPart.fileName = file.name
            multipart.addBodyPart(attachmentPart)
        }

        message.setContent(multipart)
        //发送邮件
        Transport.send(message)
    }

    /**
     * 邮件配置类(Builder模式)
     */
    class EmailConfig {
        var host: String? = null
        var port: String? = null
        var username: String? = null
        var password: String? = null
        var from: String? = null
        var to: String? = null
        var subject: String = "Application Log Report"
        var body: String = "Automatically Sent Log File"
        var listener: OnSendListener? = null

        fun setHost(host: String?): EmailConfig {
            this.host = host
            return this
        }

        fun setPort(port: String?): EmailConfig {
            this.port = port
            return this
        }

        fun setUsername(username: String): EmailConfig {
            this.username = username
            return this
        }

        fun setPassword(password: String): EmailConfig {
            this.password = password
            return this
        }

        fun setFrom(from: String): EmailConfig {
            this.from = from
            return this
        }

        fun setTo(to: String): EmailConfig {
            this.to = to
            return this
        }

        fun setSubject(subject: String): EmailConfig {
            this.subject = subject
            return this
        }

        fun setBody(body: String): EmailConfig {
            this.body = body
            return this
        }

        fun setSendListener(listener: OnSendListener?): EmailConfig {
            this.listener = listener
            return this
        }
    }

    /**
     * 发送结果回调接口
     */
    interface OnSendListener {
        fun onSuccess()
        fun onFailure(e: Exception?)
    }
}

3、调用

我们是找个地方点击5下就触发发送邮件的逻辑,限debug环境。

kotlin 复制代码
import android.os.Handler
import android.os.Looper
import android.view.View

/**
 * @description 自定义连续点击多次的监听器
 */

class MultiClickListener(
    private val requiredClicks: Int,
    private val intervalMillis: Long,
    private val action: () -> Unit
) : View.OnClickListener {

    private var clickCount = 0
    private val handler = Handler(Looper.getMainLooper())
    private val resetRunnable = Runnable { clickCount = 0 }

    override fun onClick(v: View) {
        handler.removeCallbacks(resetRunnable)
        clickCount++

        if (clickCount >= requiredClicks) {
            action.invoke()
            clickCount = 0
        } else {
            handler.postDelayed(resetRunnable, intervalMillis)
        }
    }
}

// 扩展函数用法
fun View.setMultiClickListener(requiredClicks: Int, intervalMillis: Long = 500, action: () -> Unit) {
    setOnClickListener(MultiClickListener(requiredClicks, intervalMillis, action))
}
kotlin 复制代码
//点击5次触发上传邮件
binding.imgLogo.setMultiClickListener(5) {
    lifecycleScope.launch {
        showToast("Start collecting logs and sending email...")
        LogMailSender.sendLogDirectory(this@AboutXxxActivity, object : LogMailSender.OnSendListener {
            override fun onSuccess() {
                lifecycleScope.launch {
                    showToast("Email sent successfully")
                }
            }

            override fun onFailure(e: Exception?) {
                lifecycleScope.launch {
                    showToast("Email sending failed:${e?.toString()}")
                }
            }
        })
    }
}

四、实现的效果

邮件的内容是可以完全自定义的,上面的代码可以实现如下的效果

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui