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()}")
                }
            }
        })
    }
}

四、实现的效果

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

相关推荐
雨白10 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk10 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING11 小时前
RN容器启动优化实践
android·react native
恋猫de小郭13 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker19 小时前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴19 小时前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭1 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab1 天前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe1 天前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农2 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos