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

四、实现的效果

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

相关推荐
2501_9151063217 小时前
iOS 打包 IPA 全流程详解,签名配置、工具选择与跨平台上传实战指南
android·macos·ios·小程序·uni-app·cocoa·iphone
超低空17 小时前
Android MediaSession深度解析:车载音乐播放器完整案例
android·架构·客户端
QmDeve17 小时前
Android 集成与使用模糊开关按钮视图 (BlurSwitchButtonView)
android·github
00后程序员张17 小时前
iOS 混淆实操指南多工具组合实现 IPA 混淆、加固与发布治理 IPA 加固
android·ios·小程序·https·uni-app·iphone·webview
xiaoshiquan120618 小时前
as强制过滤指定依赖版本库,解决该依赖不同版本冲突
android
2501_9291576820 小时前
Switch 20.5.0系统最新PSP模拟器懒人包
android·游戏·ios·pdf
用户0921 小时前
Kotlin Flow的6个必知高阶技巧
android·面试·kotlin
用户0921 小时前
Flutter插件与包的本质差异
android·flutter·面试
用户091 天前
Jetpack Compose静态与动态CompositionLocal深度解析
android·面试·kotlin
聆风吟º1 天前
【Spring Boot 报错已解决】别让端口配置卡壳!Spring Boot “Binding to target failed” 报错解决思路
android·java·spring boot